@@ -266,3 +266,217 @@ test.describe("conditional-text excludes content correctly", () => {
266266 } ) ;
267267 }
268268} ) ;
269+
270+ // Regression guard for solo-io/docs#2389: the module's pager partial overrides
271+ // hextra's default to include section index pages (`_index.md`) as navigable
272+ // siblings. If anyone reverts to hextra's default, section landings silently
273+ // stop rendering prev/next links.
274+ test . describe ( "section-index pager (PR 2389 regression guard)" , ( ) => {
275+ const sectionRel = path . join (
276+ target . baseURL . replace ( / ^ \/ + | \/ + $ / g, "" ) || "" ,
277+ "v2" ,
278+ "index.html" ,
279+ ) ;
280+ const sectionIndex = path . join ( target . builtRoot , sectionRel ) ;
281+
282+ test . skip (
283+ ! fs . existsSync ( sectionIndex ) ,
284+ "section index /v2/ not present in builtRoot (fixture-only check)" ,
285+ ) ;
286+
287+ test ( "v2 section landing renders a pager linking to a sibling section" , ( ) => {
288+ const html = readFixture ( sectionIndex ) ;
289+ // The pager wrapper has a unique Tailwind class signature emitted by
290+ // layouts/partials/components/pager.html. If the partial is dropped or
291+ // hextra's default takes over (which skips _index pages), this match
292+ // fails and the section landing silently loses navigation.
293+ const pagerRe =
294+ / < d i v c l a s s = " h x : m b - 8 h x : f l e x h x : i t e m s - c e n t e r h x : b o r d e r - t [ ^ " ] * " [ ^ > ] * > ( [ \s \S ] * ?) < \/ d i v > / ;
295+ const pagerMatch = html . match ( pagerRe ) ;
296+ expect ( pagerMatch , "no pager rendered on /v2/" ) . not . toBeNull ( ) ;
297+ const inner = pagerMatch ! [ 1 ] ;
298+ const hrefs = [ ...inner . matchAll ( / < a [ ^ > ] + h r e f = " ( [ ^ " ] + ) " / g) ] . map ( ( m ) => m [ 1 ] ) ;
299+ expect ( hrefs . length , "pager has no <a> children" ) . toBeGreaterThan ( 0 ) ;
300+ // Every pager href on /v2/ should point at a sibling section landing
301+ // (v1, main, etc.) — never at a non-existent path. Strip baseURL and
302+ // expect it to match one of the configured sibling versions.
303+ const baseAbs = "/" + target . baseURL . replace ( / ^ \/ + | \/ + $ / g, "" ) ;
304+ const siblingVersions = target . versions . filter ( ( v ) => v !== "v2" ) ;
305+ for ( const href of hrefs ) {
306+ const rel = href . replace ( baseAbs , "" ) . replace ( / \/ $ / , "" ) . replace ( / ^ \/ + / , "" ) ;
307+ expect (
308+ siblingVersions ,
309+ `pager href ${ href } should point at a sibling section` ,
310+ ) . toContain ( rel ) ;
311+ }
312+ } ) ;
313+ } ) ;
314+
315+ // Regression guard for solo-io/docs#2416: numbered-list counter integrity.
316+ // The bug was triggered by a `{{% version %}}` shortcode wrapping a sub-list
317+ // item, which broke CSS counter increments and produced gaps in step numbers.
318+ // We don't reproduce the exact pattern here, but the simpler check — that
319+ // the fixture's 3-level ordered list renders with the expected <ol>/<li>
320+ // structure — catches any regression that mangles list nesting.
321+ test . describe ( "ordered-list structure (PR 2416 regression guard)" , ( ) => {
322+ const everythingV2 = path . join ( TEST_PRODUCT_ROOT , "v2" , "everything" , "index.html" ) ;
323+ test . skip (
324+ ! fs . existsSync ( everythingV2 ) ,
325+ "v2/everything not present in builtRoot (fixture-only check)" ,
326+ ) ;
327+
328+ test ( "3-level ordered list preserves nesting: outer 2 items, each with nested <ol>" , ( ) => {
329+ const html = readFixture ( everythingV2 ) ;
330+ // Locate the "Ordered (3 levels)" section heading, then capture the
331+ // first <ol> that follows it. This is the fixture's canonical
332+ // three-level list from everything.md ("Ordered (3 levels)" subsection).
333+ const headingIdx = html . indexOf ( 'id="ordered-3-levels"' ) ;
334+ expect ( headingIdx , "ordered-3-levels heading not found" ) . toBeGreaterThan ( - 1 ) ;
335+ const tail = html . slice ( headingIdx ) ;
336+ const olStart = tail . indexOf ( "<ol" ) ;
337+ expect ( olStart , "no <ol> follows the heading" ) . toBeGreaterThan ( - 1 ) ;
338+ // Extract the balanced top <ol> ... </ol> (with one level of nesting).
339+ // Hugo emits this as well-formed HTML so a simple depth counter works.
340+ let depth = 0 ;
341+ let i = olStart ;
342+ let endIdx = - 1 ;
343+ while ( i < tail . length ) {
344+ if ( tail . startsWith ( "<ol" , i ) ) {
345+ depth ++ ;
346+ i += 3 ;
347+ } else if ( tail . startsWith ( "</ol>" , i ) ) {
348+ depth -- ;
349+ if ( depth === 0 ) {
350+ endIdx = i + 5 ;
351+ break ;
352+ }
353+ i += 5 ;
354+ } else {
355+ i ++ ;
356+ }
357+ }
358+ expect ( endIdx , "unbalanced <ol> tags" ) . toBeGreaterThan ( - 1 ) ;
359+ const outerOl = tail . slice ( olStart , endIdx ) ;
360+ // Count direct-child <li> elements at the outer level. A naive count
361+ // would include nested <li>; instead, count <li> *after* slicing out
362+ // any nested <ol>...</ol> blocks first.
363+ const outerWithoutNested = stripNested ( outerOl , "<ol" , "</ol>" ) ;
364+ const outerLiCount = ( outerWithoutNested . match ( / < l i \b / g) ?? [ ] ) . length ;
365+ expect (
366+ outerLiCount ,
367+ "top-level ordered list should have exactly 2 items" ,
368+ ) . toBe ( 2 ) ;
369+ // The list MUST contain at least one nested <ol> (the bug pattern is
370+ // that nesting collapses). Two <ol> total: outer + one nested.
371+ const nestedOlCount = ( outerOl . match ( / < o l \b / g) ?? [ ] ) . length ;
372+ expect (
373+ nestedOlCount ,
374+ "list lost its nesting — expected outer + at least one nested <ol>" ,
375+ ) . toBeGreaterThan ( 1 ) ;
376+ } ) ;
377+ } ) ;
378+
379+ // Helper: remove every balanced `<openTag...</closeTag>` block from a string.
380+ // Used to count direct children at a given nesting depth.
381+ function stripNested ( s : string , openTag : string , closeTag : string ) : string {
382+ let out = "" ;
383+ let i = 0 ;
384+ // Skip the very first opening tag (we want to keep it as the outer wrapper).
385+ const first = s . indexOf ( openTag ) ;
386+ if ( first < 0 ) return s ;
387+ out += s . slice ( 0 , first + openTag . length ) ;
388+ i = first + openTag . length ;
389+ while ( i < s . length ) {
390+ const nextOpen = s . indexOf ( openTag , i ) ;
391+ const nextClose = s . indexOf ( closeTag , i ) ;
392+ if ( nextOpen < 0 || nextClose < 0 || nextClose < nextOpen ) {
393+ out += s . slice ( i ) ;
394+ break ;
395+ }
396+ // Found a nested open before the next close — skip everything between
397+ // the nested open and its matching close.
398+ out += s . slice ( i , nextOpen ) ;
399+ let depth = 1 ;
400+ let j = nextOpen + openTag . length ;
401+ while ( j < s . length && depth > 0 ) {
402+ if ( s . startsWith ( openTag , j ) ) {
403+ depth ++ ;
404+ j += openTag . length ;
405+ } else if ( s . startsWith ( closeTag , j ) ) {
406+ depth -- ;
407+ j += closeTag . length ;
408+ } else {
409+ j ++ ;
410+ }
411+ }
412+ i = j ;
413+ }
414+ return out ;
415+ }
416+
417+ // Regression guard for solo-io/docs#2414: title badges. The fixture sets
418+ // every badge flag (enterprise/alpha/beta/oss/experimental) on v2/everything.md
419+ // so a single page exercises all five variants. v1/everything.md has none
420+ // of them, covering the negative case.
421+ test . describe ( "title badges (PR 2414 regression guard)" , ( ) => {
422+ const v2Everything = path . join (
423+ TEST_PRODUCT_ROOT ,
424+ "v2" ,
425+ "everything" ,
426+ "index.html" ,
427+ ) ;
428+ const v1Everything = path . join (
429+ TEST_PRODUCT_ROOT ,
430+ "v1" ,
431+ "everything" ,
432+ "index.html" ,
433+ ) ;
434+
435+ test . skip (
436+ ! fs . existsSync ( v2Everything ) ,
437+ "v2/everything not present in builtRoot (fixture-only check)" ,
438+ ) ;
439+
440+ test ( "v2/everything renders .page-badges with all five badge variants" , ( ) => {
441+ const html = readFixture ( v2Everything ) ;
442+ // Pull out the .page-badges block. The module's docs/single.html only
443+ // emits this wrapper when at least one flag is truthy.
444+ const badgesMatch = html . match (
445+ / < d i v c l a s s = " p a g e - b a d g e s " > ( [ \s \S ] * ?) < \/ d i v > / ,
446+ ) ;
447+ expect (
448+ badgesMatch ,
449+ ".page-badges container missing — badge front-matter not honored" ,
450+ ) . not . toBeNull ( ) ;
451+ const inner = badgesMatch ! [ 1 ] ;
452+ // Each badge is a <span class="section-card-badge ...">LABEL</span>.
453+ // Assert every label appears.
454+ const expectedLabels = [ "Enterprise" , "Alpha" , "Beta" , "Open Source" , "Experimental" ] ;
455+ for ( const label of expectedLabels ) {
456+ expect (
457+ inner ,
458+ `badge "${ label } " should render when its front-matter flag is true` ,
459+ ) . toContain ( label ) ;
460+ }
461+ // Variant classes: the alpha/beta/experimental badges should carry
462+ // badge-tag, the oss badge carries badge-oss. Catches silent CSS-class
463+ // regressions that would render the right text in the wrong color.
464+ expect ( inner , ".badge-tag should apply to alpha/beta/experimental" ) . toMatch (
465+ / s e c t i o n - c a r d - b a d g e b a d g e - t a g / ,
466+ ) ;
467+ expect ( inner , ".badge-oss should apply to the Open Source badge" ) . toMatch (
468+ / s e c t i o n - c a r d - b a d g e b a d g e - o s s / ,
469+ ) ;
470+ } ) ;
471+
472+ test ( "v1/everything (no badge flags) does NOT render .page-badges" , ( ) => {
473+ if ( ! fs . existsSync ( v1Everything ) ) {
474+ test . skip ( true , "v1/everything not present in builtRoot" ) ;
475+ }
476+ const html = readFixture ( v1Everything ) ;
477+ expect (
478+ html ,
479+ "page-badges container leaked onto a page with no badge front-matter" ,
480+ ) . not . toContain ( 'class="page-badges"' ) ;
481+ } ) ;
482+ } ) ;
0 commit comments