From 8323ac4e101c9d6e4441685449bad8ef2b52b506 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 11:36:17 +0100 Subject: [PATCH 1/9] DOC-6939 Correct stale document count in the NDJSON feed docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page claimed both feed files contain ~4,100 documents; the live feed has 2,643 (2,611 content + 32 index). The applied AI team's source assessment flagged it because a consumer taking the figure at face value concludes a third of the corpus is missing. Replaced it with a growth-safe phrasing instead of the true figure on purpose — the count climbs by roughly one a day (2,637 on their 31 July run, 2,643 five days later), so a hardcoded exact number is precisely what rotted last time. While verifying the feed I also refuted their observation 04, which claimed content_hash coverage is undocumented and might exclude code. Both halves are wrong, and the evidence is on this same page. The algorithm is published a few sections further down, and it hashes the summary plus every section text plus every example code block, reproducing the published hash for all 2,611 content pages including the 1,211 that carry examples. So a code-only edit does change the hash, and consumers can safely gate refresh on it alone rather than re-fetching to confirm. Their other four measurable claims reproduced exactly against today's feed (2,119 distinct ids for 2,643 URLs, 15 role values, 103 placeholder mismatches, 6 sections with residual shortcodes), so the rest of the assessment is trustworthy where it is measurable. Learned: content_hash provably covers examples[].code, so the feed hash is safe to gate a refresh on Constraint: the hash algorithm published on this page must track computeContentHash in build/transform_json_sections.ts Directive: do not replace "more than 2,600" with an exact count — an exact figure is what went stale here Rejected: emit the live count from build/generate_ndjson.py into a Hugo data file | needs a build change, disproportionate to a one-line docs fix Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- content/ai-agent-resources.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/ai-agent-resources.md b/content/ai-agent-resources.md index 8eb6fc43b9..74765c041f 100644 --- a/content/ai-agent-resources.md +++ b/content/ai-agent-resources.md @@ -28,7 +28,7 @@ A single file containing all documentation pages in [NDJSON](https://github.com/ | NDJSON | [docs.ndjson](https://redis.io/docs/latest/docs.ndjson) | ~30 MB | | Gzipped | [docs.ndjson.gz](https://redis.io/docs/latest/docs.ndjson.gz) | ~5 MB | -Both files contain ~4,100 documents. +Both files contain one record per documentation page, currently more than 2,600. ### Per-page JSON From 9d1b68d83dc554538a0158c95acdb0552feb60dc Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 12:01:50 +0100 Subject: [PATCH 2/9] DOC-6939 Expand table-children shortcodes in JSON/Markdown output Adds a markdown-table-children partial so the AI-facing outputs render the same table the HTML shortcode does, and fixes the shortcode-strip regex that let six of them leak raw into the feed. The reported symptom was six release-notes pages carrying unexpanded shortcodes, which looked like a shortcode missing from the pipeline's allowlist. It isn't. 101 pages use table-children, and the six that leak are exactly the six whose attributes contain a literal ">" -- their columnNames use
to wrap header text. The strip pattern was a [^>]* class, which cannot cross that ">", so the match fails and the whole shortcode survives verbatim. Made it non-greedy to the first ">}}" instead. Fixing the strip alone would have been the wrong fix, and finding out why is the useful part. The other 95 pages were being stripped "successfully" and silently losing the entire table -- and on the release-notes index pages the table IS the body, so those pages reached the feed with either nothing or a dangling colon ("Here are the most recent changes for Redis Insight:" followed by no changes). The table's version columns are worse: children[] carries only id, summary, title and url, so the minimum Redis and cluster versions existed nowhere else in the feed. The leak was the visible 6% of a defect affecting all 101 pages, and only the leak was reported because a shortcode-shaped regex is what anyone checks for. The leading newline in the replacement is deliberate and load-bearing. Without it, on pages whose whole body is the shortcode, the table opens on the same line as the closing fence of the Markdown metadata block, which stops that being a valid closing fence and swallows the table into the code block. That pre-existing fusing affects 1,383 pages site-wide and is not fixed here; the newline just keeps this change from adding to it, and happens to clear 7. Verified by building the site twice, before and after, and diffing every page's JSON content: 5,631 byte-identical, 101 changed, 0 changed that don't use the shortcode. Markdown row counts match the HTML table row counts on all 101, including both limitTags filters and the one from= indirection. Learned: the six leaking pages were the visible edge of a defect dropping the table from all 101, so the reported symptom understated it by 16x Constraint: never match shortcodes with a [^>] class -- attribute values legitimately contain > (columnNames uses
) Directive: keep the leading newline in the table replacement or the table gets swallowed by the metadata block's closing fence Rejected: fix only the strip regex | makes the 6 consistent with the other 95 but leaves all 101 tables missing from AI output Gaps: the 1,383-page fused closing fence in .md output is pre-existing, unfixed, and needs its own change Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- layouts/partials/markdown-table-children.html | 126 ++++++++++++++++++ .../partials/process-markdown-content.html | 17 ++- 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 layouts/partials/markdown-table-children.html diff --git a/layouts/partials/markdown-table-children.html b/layouts/partials/markdown-table-children.html new file mode 100644 index 0000000000..268e7dfbc4 --- /dev/null +++ b/layouts/partials/markdown-table-children.html @@ -0,0 +1,126 @@ +{{- /* + Expand table-children shortcodes for Markdown/JSON output. + + The HTML shortcode (layouts/shortcodes/table-children.html) renders an HTML + listing a page's children, taking each column from a child page + param. AI-facing output needs the same data as a Markdown table. + + Without this partial the shortcode is stripped by process-markdown-content.html, + and the release-notes index pages -- whose entire body is the table -- lose all + their content. The version columns (min-version-db, min-version-rs) appear + nowhere else in the JSON feed, so they were being dropped outright. + + Input: dict with: + - "RawContent": The Markdown content to process (already HTML-unescaped + by the caller, so shortcodes appear as {{< table-children ... >}}) + - "Page": The current Hugo page (for .Pages and .GetPage) + + Output: Markdown content with table-children shortcodes expanded +*/ -}} + +{{- $content := .RawContent -}} +{{- $page := .Page -}} + +{{- /* Match non-greedily up to the first ">}}". Attribute values legitimately + contain ">" -- columnNames uses
to wrap header text -- so a [^>]* + class stops at that ">" and never matches the shortcode at all. */ -}} +{{- $pattern := `(?s)\{\{<\s*table-children.*?>\}\}` -}} + +{{- range $match := (findRE $pattern $content) -}} + + {{- /* Pull the shortcode's named parameters out of the matched text */ -}} + {{- $names := "" -}} + {{- $sources := "" -}} + {{- $links := "" -}} + {{- $limitTags := "" -}} + {{- $from := "" -}} + + {{- with (findRESubmatch `columnNames="([^"]*)"` $match 1) -}} + {{- $names = index (index . 0) 1 -}} + {{- end -}} + {{- with (findRESubmatch `columnSources="([^"]*)"` $match 1) -}} + {{- $sources = index (index . 0) 1 -}} + {{- end -}} + {{- with (findRESubmatch `enableLinks="([^"]*)"` $match 1) -}} + {{- $links = index (index . 0) 1 -}} + {{- end -}} + {{- with (findRESubmatch `limitTags="([^"]*)"` $match 1) -}} + {{- $limitTags = index (index . 0) 1 -}} + {{- end -}} + {{- with (findRESubmatch `from="([^"]*)"` $match 1) -}} + {{- $from = index (index . 0) 1 -}} + {{- end -}} + + {{- /* Same child selection as the HTML shortcode, so both outputs agree on + contents and ordering */ -}} + {{- $children := $page.Pages -}} + {{- if $from -}} + {{- with $page.GetPage $from -}} + {{- $children = .Pages -}} + {{- end -}} + {{- end -}} + + {{- $replacement := "" -}} + + {{- if and $names $sources -}} + {{- $sourceList := split $sources "," -}} + {{- $linkList := split $links "," -}} + + {{- /* Header: strip the presentational markup the HTML table relies on + (  spacers and
line breaks) and collapse the whitespace */ -}} + {{- $headerCells := slice -}} + {{- range $name := (split $names ",") -}} + {{- $clean := $name | replaceRE ` ?` " " | replaceRE `` " " -}} + {{- $clean = trim (replaceRE `\s+` " " $clean) " " -}} + {{- $headerCells = $headerCells | append $clean -}} + {{- end -}} + + {{- $rows := slice -}} + {{- range $child := $children -}} + {{- $addRow := true -}} + {{- if $limitTags -}} + {{- $childTags := $child.Param "tags" -}} + {{- if not (in $childTags $limitTags) -}} + {{- $addRow = false -}} + {{- end -}} + {{- end -}} + + {{- if $addRow -}} + {{- $cells := slice -}} + {{- range $source := $sourceList -}} + {{- $value := printf "%v" ($child.Param $source | default "") -}} + {{- /* Keep each row on one line and don't let a value break out of + its table cell */ -}} + {{- $value = trim (replaceRE `\s+` " " $value) " " -}} + {{- $value = replace $value "|" `\|` -}} + {{- if in $linkList $source -}} + {{- if $value -}} + {{- $value = printf "[%s](%s)" $value $child.Permalink -}} + {{- end -}} + {{- end -}} + {{- $cells = $cells | append $value -}} + {{- end -}} + {{- $rows = $rows | append (printf "| %s |" (delimit $cells " | ")) -}} + {{- end -}} + {{- end -}} + + {{- if $rows -}} + {{- $divider := slice -}} + {{- range $headerCells -}} + {{- $divider = $divider | append "---" -}} + {{- end -}} + {{- /* Leading newline is load-bearing: on pages whose entire body is this + shortcode, the table would otherwise start on the same line as the + closing ``` of the Markdown metadata block, which stops it being a + valid closing fence and swallows the table into the code block. */ -}} + {{- $replacement = printf "\n| %s |\n| %s |\n%s\n" + (delimit $headerCells " | ") + (delimit $divider " | ") + (delimit $rows "\n") -}} + {{- end -}} + {{- end -}} + + {{- $content = replace $content $match $replacement 1 -}} +{{- end -}} + +{{- return $content -}} diff --git a/layouts/partials/process-markdown-content.html b/layouts/partials/process-markdown-content.html index b5ccccab03..6ec1a9726e 100644 --- a/layouts/partials/process-markdown-content.html +++ b/layouts/partials/process-markdown-content.html @@ -75,8 +75,23 @@ {{- $content = $content | replaceRE "'" "'" -}} {{- $content = $content | replaceRE "+" "+" -}} +{{- /* Expand table-children shortcodes into Markdown tables. Runs after the unescape + above (it matches literal {{< ... >}}) and before the strip below, which would + otherwise discard the table -- taking the whole body of every release-notes + index page with it. */ -}} +{{- $content = partial "markdown-table-children.html" (dict "RawContent" $content "Page" .Page) -}} +{{- /* Unescape again, as Hugo re-escapes partial output */ -}} +{{- $content = $content | replaceRE """ "\"" -}} +{{- $content = $content | replaceRE """ "\"" -}} +{{- $content = $content | replaceRE "'" "'" -}} +{{- $content = $content | replaceRE "<" "<" -}} +{{- $content = $content | replaceRE ">" ">" -}} +{{- $content = $content | replaceRE "&" "&" -}} + {{- /* Remove remaining shortcodes AFTER unescape (content now has literal < and >) */ -}} -{{- $content = $content | replaceRE `\{\{<\s*/?[^>]*>\}\}` "" -}} +{{- /* Match non-greedily to the first ">}}": a [^>]* class is defeated by a ">" inside + an attribute value (e.g. columnNames="...
...") and leaks the raw shortcode. */ -}} +{{- $content = $content | replaceRE `(?s)\{\{<\s*/?.*?>\}\}` "" -}} {{- $content = $content | replaceRE `\{\{%\s*/?[^%]*%\}\}` "" -}} {{- return $content -}} From 60847d4d9af5c5319d4b5bdb298e30d522fa98f3 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 13:04:47 +0100 Subject: [PATCH 3/9] DOC-6939 Separate page content from the metadata block in Markdown output The closing fence of the metadata block was landing on the same line as the first line of page content, on 1,223 of 5,732 pages. A closing fence may be followed only by whitespace, so it stopped closing anything: 860 pages went out with an unterminated code block, and on the rest a later bare fence closed it but inverted the open/close role of every fence after it, so real code examples and prose swapped places. The cause is whitespace trimming, not the content. Both single.md and section.md had the closing fence, a literal blank line, and then a template comment opening with a left-trim marker. That trim eats all preceding whitespace, so it removed the blank line and the fence's own newline, leaving the fence flush against the content. The two now emit the separator explicitly instead of relying on literal blank lines that a trim marker can reach. Two dead ends worth recording. Simply dropping the dash to stop the trim does not compile: a Go template comment must open as "{{/*" or "{{- /*", and "{{ /*" is a parse error. And even with the valid no-trim form, the following line's own left-trim reaches back through the blank line and re-fuses the fence, so no arrangement of literal whitespace is safe here. I also had the cause wrong at first, in a way worth flagging: the injected code examples legend looked like the trigger, since it is what appears fused on the client pages. It accounts for only 120 of the 1,223. The trailing text is whatever each page's content happens to begin with, so this was never specific to pages carrying code examples. Verified by diffing the Markdown output of a full build before and after, with the edited region normalised on both sides: 5,732 files identical outside it, zero collateral change. Fused fences 1,223 to 0, unterminated blocks 860 to 10. Note that Python-Markdown is a misleading oracle here because it is not CommonMark-compliant and reported the fix as making things worse; the number above comes from applying the fence rules directly. Learned: a Go template comment must open as {{/* or {{- /*, and "{{ /*" is a parse error, so a trim marker cannot be removed from a comment without restructuring Constraint: never separate the metadata block from page content with literal blank lines -- a following trim marker will reach back and eat them, so emit the newlines explicitly Directive: verify Hugo output against a clean build, as hugo does not empty public/ and stale alias files survive to produce phantom findings Rejected: dropping the left-trim marker from the comment | "{{ /*" does not parse, and the valid form still leaves the next line's trim to re-fuse the fence Gaps: 10 pages still emit an unterminated fence from source authoring errors (7 kubernetes logs pages and rdi installation/ha-test fuse a command onto its opening fence; 2 search-and-query pages have an odd fence count) Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- layouts/_default/section.md | 7 ++++++- layouts/_default/single.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/layouts/_default/section.md b/layouts/_default/section.md index fa9608dc5d..800ebf5e4b 100644 --- a/layouts/_default/section.md +++ b/layouts/_default/section.md @@ -25,4 +25,9 @@ {{- /* Process content with shared partial (shortcode expansion, HTML unescaping, etc.) */ -}} {{- $content := partial "process-markdown-content.html" (dict "RawContent" .RawContent "Site" .Site "Page" .) -}} -{{ $content }} +{{- /* The leading newlines are emitted explicitly rather than left as literal blank + lines in this template, because the trim markers above would swallow them: the + content would then start on the same line as the metadata block's closing fence. + A closing fence may be followed only by whitespace, so it would stop closing + anything and the rest of the page would be read as part of the code block. */ -}} +{{ printf "\n\n%s" $content }} diff --git a/layouts/_default/single.md b/layouts/_default/single.md index fa9608dc5d..800ebf5e4b 100644 --- a/layouts/_default/single.md +++ b/layouts/_default/single.md @@ -25,4 +25,9 @@ {{- /* Process content with shared partial (shortcode expansion, HTML unescaping, etc.) */ -}} {{- $content := partial "process-markdown-content.html" (dict "RawContent" .RawContent "Site" .Site "Page" .) -}} -{{ $content }} +{{- /* The leading newlines are emitted explicitly rather than left as literal blank + lines in this template, because the trim markers above would swallow them: the + content would then start on the same line as the metadata block's closing fence. + A closing fence may be followed only by whitespace, so it would stop closing + anything and the rest of the page would be read as part of the code block. */ -}} +{{ printf "\n\n%s" $content }} From 3e648588bdf3716132c6247c4d60f5f801ef58a5 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 13:37:32 +0100 Subject: [PATCH 4/9] DOC-6939 Fix orphaned code placeholders and duplicate section ids Two separate defects sat behind the reported placeholder/examples mismatch, and neither is the cause the assessment guessed (a placeholder emitted for non-code blocks such as command output or tables). The first is small. extractCodeBlocks returned the [code example] placeholder unconditionally but only recorded an example when the block had content, so an empty fenced block left a placeholder with nothing in examples[] to resolve it. 16 sections. It now drops the placeholder along with the empty block. The second is the interesting one and explains the mismatches that ran the other way, which the assessment could not account for. Section ids were not unique within a page: 440 pages carried at least one collision. slugify discards punctuation, so headings that differ only in punctuation reduce to the same string -- commands/xread has "The special `$` ID." and "The special `+` ID", both becoming the-special-id. Because examples key off the section id, the two sections' examples merged into one bucket of 4 while each section's own text held only 2 placeholders, which is what surfaced as more examples than placeholders. The knock-on effects were worse than the count mismatch. examples[].id is built from the section id, so it was not unique either: 131 pages had colliding example ids, up to 5 examples sharing examples-ex0. Deep links of the form url#section-id were ambiguous, and any consumer keying sections by id silently dropped one of a colliding pair. Ids are now disambiguated with Hugo's own -1, -2 suffix convention. One divergence from Hugo is deliberate: where a page has intro prose and also an "Overview" heading, the synthetic intro section keeps the overview id and the real heading becomes overview-1, whereas Hugo would give the heading the plain anchor. Keeping the established id for the intro section matters more to existing consumers than matching the anchor in that narrow case. Mismatches counted per section object drop from 739 to 1, duplicate section ids from 440 to 0, duplicate example ids from 131 to 0. The single remaining mismatch is not a defect: ai-agent-resources documents the feed format and so writes the literal token in prose, which any placeholder count will pick up. Confirmed content_hash still reproduces from the published algorithm for all 5,687 content pages. Learned: transform_json_sections.ts is not idempotent -- it strips the content field it reads, so a second run silently turns every content page into an empty index page with no error Constraint: section ids must be unique within a page -- they join examples[].section_id, build examples[].id, and are the target of url#section-id deep links Rejected: aligning slugify with Hugo's anchor algorithm outright | changes ids on far more than the colliding pages, so it belongs with the anchor-parity work, not here Gaps: section ids still do not match rendered page anchors in general -- 79.6% on a 400-page sample -- and the {#explicit-anchor} heading defect leaking into 4,363 section titles is untouched Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- build/transform_json_sections.ts | 57 +++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/build/transform_json_sections.ts b/build/transform_json_sections.ts index e4f736a872..ff57bb8a33 100644 --- a/build/transform_json_sections.ts +++ b/build/transform_json_sections.ts @@ -96,6 +96,30 @@ const FILTERED_SECTION_IDS = new Set([ 'code-examples-legend', ]); +/** + * Make a section id unique within its page. + * + * The id is the join key for examples[].section_id and the target of + * url#section-id deep links, so collisions are not cosmetic: examples from two + * sections merge under one id, examples[].id itself stops being unique, and a + * consumer keying sections by id silently drops one of them. Headings that + * differ only in punctuation collide readily, because slugify() discards it -- + * "The special `$` ID" and "The special `+` ID" both reduce to the-special-id. + * + * Disambiguated with Hugo's own -1, -2 suffix convention. + */ +function makeUniqueId(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base); + return base; + } + let n = 1; + while (used.has(`${base}-${n}`)) n++; + const id = `${base}-${n}`; + used.add(id); + return id; +} + /** * Extract fenced code blocks from text. * Returns the code blocks and the text with code blocks removed. @@ -115,15 +139,20 @@ function extractCodeBlocks(text: string, sectionId: string): { const language = lang || 'plaintext'; const trimmedCode = code.trim(); - if (trimmedCode) { - examples.push({ - id: `${sectionId}-ex${exampleIndex++}`, - language, - code: trimmedCode, - section_id: sectionId, - }); + // An empty code block yields no example, so a placeholder here would be + // unresolvable: the consumer sees [code example] with nothing in examples[] + // to substitute back in. Drop it instead. + if (!trimmedCode) { + return ''; } + examples.push({ + id: `${sectionId}-ex${exampleIndex++}`, + language, + code: trimmedCode, + section_id: sectionId, + }); + // Replace code block with placeholder to preserve structure return '[code example]'; }); @@ -134,6 +163,8 @@ function extractCodeBlocks(text: string, sectionId: string): { function splitContentIntoSections(content: string): { sections: Section[]; examples: CodeExample[] } { const rawSections: Section[] = []; const allExamples: CodeExample[] = []; + // Tracks ids already handed out on this page, so they stay unique + const usedIds = new Set(); // First, find all code block ranges to avoid matching headings inside them const codeBlockRanges: { start: number; end: number }[] = []; @@ -174,10 +205,11 @@ function splitContentIntoSections(content: string): { sections: Section[]; examp if (matches.length === 0) { const text = content.trim(); if (text) { - const { examples, textWithoutCode } = extractCodeBlocks(text, 'content'); + const contentId = makeUniqueId('content', usedIds); + const { examples, textWithoutCode } = extractCodeBlocks(text, contentId); allExamples.push(...examples); rawSections.push({ - id: 'content', + id: contentId, title: 'Content', role: 'content', text: textWithoutCode, @@ -189,10 +221,11 @@ function splitContentIntoSections(content: string): { sections: Section[]; examp // Extract text before first heading as intro/overview const introText = content.slice(0, matches[0].index).trim(); if (introText) { - const { examples, textWithoutCode } = extractCodeBlocks(introText, 'overview'); + const overviewId = makeUniqueId('overview', usedIds); + const { examples, textWithoutCode } = extractCodeBlocks(introText, overviewId); allExamples.push(...examples); rawSections.push({ - id: 'overview', + id: overviewId, title: 'Overview', role: 'overview', text: textWithoutCode, @@ -210,7 +243,7 @@ function splitContentIntoSections(content: string): { sections: Section[]; examp const headingEnd = newlinePos === -1 ? content.length : newlinePos + 1; const sectionText = content.slice(headingEnd, nextIndex).trim(); - const id = slugify(current.title); + const id = makeUniqueId(slugify(current.title), usedIds); const role = assignRole(current.title, rawSections.length); // Extract code blocks from section text From 95bcfb11714cc39ca166ba514b1aecfef4673455 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 13:54:07 +0100 Subject: [PATCH 5/9] DOC-6939 Parse explicit heading anchors into section ids Headings using Hugo's explicit anchor syntax were not parsed, so the literal brace expression was treated as part of the heading text. For "## Specify socket file location during install {#during-install}" the feed published the syntax inside the user-visible title, an id of specify-socket-file-location-during-install-during-install, and therefore a dead deep link, while the anchor Hugo actually rendered was during-install. 4,363 sections across 773 source files. The title is now the heading without the anchor, and the id is the anchor itself. The explicit anchor is used verbatim rather than slugified. Hugo takes it as the rendered id unchanged, so passing it through slugify would produce a different string from the anchor on the page and defeat the point of the change. The match is deliberately strict, and this is the part worth knowing before touching it. Many headings end in a brace that is not an anchor: 725 Python signatures ending in an empty dict, 280 dict defaults such as extra ignore, and 145 or more relref shortcodes. A loose trailing-brace match would silently eat all of them from the titles. Measured across all 40,951 level 2 and 3 headings, the strict form and a loose form happen to agree today at 4,368 matches with no corrupting cases, and no match sits inside a code span -- but the strict form is what keeps that true when someone writes a heading ending in a dict literal. Anchor parity on the same seeded 400-page sample used to measure the problem rises from 79.6% to 91.9%. The residual is a separate, well-understood difference between slugify and Goldmark rather than anything left of this defect: Goldmark keeps dots and underscores where slugify replaces them, so application.properties becomes application-properties, and it drops apostrophes where slugify hyphenates them, so "What You'll Learn" becomes what-you-ll-learn against Goldmark's what-youll-learn. Closing that means aligning slugify with Goldmark, which moves ids on far more pages than this and is tracked as the remainder of C7 on the ticket. Verified the A3 invariants still hold: duplicate section ids and duplicate example ids both remain at zero, and content_hash still reproduces from the published algorithm for all 5,687 content pages. Learned: most braces in a heading are not anchors, so the anchor pattern has to be shape-checked rather than matched as a trailing brace, or 1,000-plus Python signatures and dict defaults lose text from their titles Constraint: an explicit heading anchor must be used verbatim as the section id, never slugified, or it stops matching the anchor Hugo renders Gaps: 8.1% of section ids still differ from the rendered anchor because slugify and Goldmark disagree on dots, underscores and apostrophes Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- build/transform_json_sections.ts | 34 +++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/build/transform_json_sections.ts b/build/transform_json_sections.ts index ff57bb8a33..590d9fa45e 100644 --- a/build/transform_json_sections.ts +++ b/build/transform_json_sections.ts @@ -84,6 +84,33 @@ function assignRole(title: string, index: number): string { return 'content'; } +/** + * Hugo's explicit heading anchor syntax, e.g. "## Change the socket path {#after-setup}". + * + * Deliberately strict. Plenty of headings end in a brace that is NOT an anchor -- + * Python signatures ending "connection_kwargs={}", dict defaults such as + * "{'extra': 'ignore'}", and relref shortcodes -- and a loose trailing-brace match + * would silently eat them. Requires "{#", then anchor-shaped characters only, then + * "}" at end of the heading. + */ +const EXPLICIT_ANCHOR = /\s*\{#([A-Za-z0-9][A-Za-z0-9_.:-]*)\}\s*$/; + +/** + * Split a heading into its display title and its id. + * + * Hugo takes the explicit anchor as the rendered heading's id verbatim and does not + * show it, so the title must have it removed and the id must not be slugified -- + * slugifying would produce a different string from the anchor on the page and break + * url#section-id deep links. + */ +function parseHeading(raw: string): { title: string; id: string } { + const match = raw.match(EXPLICIT_ANCHOR); + if (match) { + return { title: raw.replace(EXPLICIT_ANCHOR, '').trim(), id: match[1] }; + } + return { title: raw, id: slugify(raw) }; +} + function slugify(text: string): string { return text .toLowerCase() @@ -243,8 +270,9 @@ function splitContentIntoSections(content: string): { sections: Section[]; examp const headingEnd = newlinePos === -1 ? content.length : newlinePos + 1; const sectionText = content.slice(headingEnd, nextIndex).trim(); - const id = makeUniqueId(slugify(current.title), usedIds); - const role = assignRole(current.title, rawSections.length); + const { title, id: baseId } = parseHeading(current.title); + const id = makeUniqueId(baseId, usedIds); + const role = assignRole(title, rawSections.length); // Extract code blocks from section text const { examples, textWithoutCode } = extractCodeBlocks(sectionText, id); @@ -252,7 +280,7 @@ function splitContentIntoSections(content: string): { sections: Section[]; examp rawSections.push({ id, - title: current.title, + title, role, text: textWithoutCode, }); From e974a9401bfb169b75fcabac3506f36dfe1c5597 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 14:17:58 +0100 Subject: [PATCH 6/9] DOC-6939 Address Bugbot review on the feed output partials Three fixes from the Bugbot review of this branch. The third is the substantive one and it was worse than reported. Bugbot flagged that the metadata tableOfContents ids no longer agree with the JSON sections[] ids now that sections carry explicit anchors and dedup suffixes. True, but on reading toc-from-markdown.html the TOC turned out to still have both of the defects just fixed on the JSON side: the literal brace expression sitting in its user-visible titles, and the anchor concatenated onto the slug instead of replacing it. So change-location-socket-files was publishing a TOC entry titled "Specify socket file location during install {#during-install}" with the id specify-socket-file-location-during-install-during-install. The same strict anchor pattern and the same suffix convention now apply there, taking brace leakage in TOC titles to zero and duplicate TOC ids to zero. That leaves a divergence worth knowing about before touching any of this: there are now three separate slug implementations in play, and they disagree. The TOC strips inline formatting then deletes anything outside a-z0-9 and hyphen, so Pub/Sub becomes pubsub; slugify in the transform replaces punctuation runs with a hyphen, so the same heading becomes pub-sub; Goldmark does a third thing. About 9.2% of TOC ids still do not appear in sections[] for that reason alone. Consolidating the three is the same work as the anchor-parity item on the ticket. The table-children partial no longer falls back to the current page's children when a from= target fails to resolve, which would publish a plausible-looking table of the wrong data. Bugbot's stated reason for this one does not hold up -- it says the HTML shortcode renders an empty table in that case, whereas the HTML dereferences .Pages on a nil page and should error instead. I did not run it to confirm, and the fix does not depend on which is right, since silently emitting another page's rows is wrong either way. The partial also now emits the header row when every row is filtered out, matching the HTML shortcode, which always renders thead. Unreachable today, as all 101 pages using the shortcode have rows, but a table that filtered down to nothing would otherwise blank the entire body of the release-notes index pages, which is the defect this branch started from. One process note, because it nearly went into a report as a regression: the row-count parity check reads the page's content field, and the transform strips that field. Run after the transform it silently compares against an empty string and reports all 101 pages as broken. Re-pointed at sections[] text it is 101 of 101 matching, with residual shortcodes still at zero. Learned: three slug implementations now exist across toc-from-markdown.html, transform_json_sections.ts and Goldmark, and they disagree on punctuation, which caps TOC-to-sections id agreement at about 91% Constraint: the explicit-anchor parsing and the id dedup convention are implemented twice, in toc-from-markdown.html and in build/transform_json_sections.ts, and must be changed together or the metadata TOC and the JSON sections drift apart again Directive: when verifying this pipeline, read the artifact for the stage you are at -- the transform deletes the content field it consumes, so a post-transform check of content compares against empty and reports false failures Rejected: Bugbot's premise that the HTML shortcode renders an empty table for an unresolvable from= | it dereferences .Pages on nil and should error, so the fix rests on avoiding silent wrong data instead Gaps: about 9.2% of TOC ids still differ from sections[] ids purely from the competing slug implementations, unfixed here Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- layouts/partials/markdown-table-children.html | 32 ++++++++++------- layouts/partials/toc-from-markdown.html | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/layouts/partials/markdown-table-children.html b/layouts/partials/markdown-table-children.html index 268e7dfbc4..f2078d5afb 100644 --- a/layouts/partials/markdown-table-children.html +++ b/layouts/partials/markdown-table-children.html @@ -55,6 +55,10 @@ contents and ordering */ -}} {{- $children := $page.Pages -}} {{- if $from -}} + {{- /* Start empty when from= is given: if the target does not resolve, falling + back to this page's own children would publish a plausible-looking table + of the wrong data into the feed. No rows is the safe answer. */ -}} + {{- $children = slice -}} {{- with $page.GetPage $from -}} {{- $children = .Pages -}} {{- end -}} @@ -104,19 +108,23 @@ {{- end -}} {{- end -}} + {{- $divider := slice -}} + {{- range $headerCells -}} + {{- $divider = $divider | append "---" -}} + {{- end -}} + {{- /* Leading newline is load-bearing: on pages whose entire body is this + shortcode, the table would otherwise start on the same line as the + closing ``` of the Markdown metadata block, which stops it being a + valid closing fence and swallows the table into the code block. */ -}} + {{- /* The header is emitted even with no rows, matching the HTML shortcode, + which always renders thead. Otherwise a table that filters down to + nothing would remove the whole body of a page whose only content is + this shortcode. */ -}} + {{- $replacement = printf "\n| %s |\n| %s |\n" + (delimit $headerCells " | ") + (delimit $divider " | ") -}} {{- if $rows -}} - {{- $divider := slice -}} - {{- range $headerCells -}} - {{- $divider = $divider | append "---" -}} - {{- end -}} - {{- /* Leading newline is load-bearing: on pages whose entire body is this - shortcode, the table would otherwise start on the same line as the - closing ``` of the Markdown metadata block, which stops it being a - valid closing fence and swallows the table into the code block. */ -}} - {{- $replacement = printf "\n| %s |\n| %s |\n%s\n" - (delimit $headerCells " | ") - (delimit $divider " | ") - (delimit $rows "\n") -}} + {{- $replacement = printf "%s%s\n" $replacement (delimit $rows "\n") -}} {{- end -}} {{- end -}} diff --git a/layouts/partials/toc-from-markdown.html b/layouts/partials/toc-from-markdown.html index c272e73b80..ef4350f6a8 100644 --- a/layouts/partials/toc-from-markdown.html +++ b/layouts/partials/toc-from-markdown.html @@ -14,6 +14,11 @@ {{- $sections := slice -}} +{{- /* Ids already used on this page, so duplicates can be suffixed rather than + repeated. Keeps the TOC in step with sections[] in the JSON output, which + does the same in build/transform_json_sections.ts. */ -}} +{{- $usedIds := slice -}} + {{- /* Find all ## and ### headers in the raw markdown */ -}} {{- /* Pattern matches lines starting with ## or ### followed by space and title */ -}} {{- $headerPattern := `(?m)^(#{2,3}) +(.+)$` -}} @@ -45,6 +50,19 @@ {{- /* Trim whitespace */ -}} {{- $title = $title | strings.TrimSpace -}} + {{- /* Hugo's explicit heading anchor, "## Title {#custom-id}". Hugo uses the + anchor as the rendered heading id and does not display it, so it has to come + out of the title and be used verbatim as the id. Matched strictly, because a + loose trailing-brace match would eat Python signatures ending in an empty + dict and dict defaults such as {'extra': 'ignore'}. */ -}} + {{- $explicitId := "" -}} + {{- with (findRESubmatch `\s*\{#([A-Za-z0-9][A-Za-z0-9_.:-]*)\}\s*$` $title 1) -}} + {{- $explicitId = index (index . 0) 1 -}} + {{- end -}} + {{- if $explicitId -}} + {{- $title = $title | replaceRE `\s*\{#[A-Za-z0-9][A-Za-z0-9_.:-]*\}\s*$` "" | strings.TrimSpace -}} + {{- end -}} + {{- /* Generate ID (slug) from title */ -}} {{- $id := $title | lower -}} {{- /* Replace spaces with hyphens */ -}} @@ -56,6 +74,24 @@ {{- /* Trim leading/trailing hyphens */ -}} {{- $id = $id | replaceRE `^-+|-+$` "" -}} + {{- /* An explicit anchor overrides the slug entirely */ -}} + {{- if $explicitId -}} + {{- $id = $explicitId -}} + {{- end -}} + + {{- /* Disambiguate a repeated id with Hugo's own -1, -2 suffix convention */ -}} + {{- if in $usedIds $id -}} + {{- $base := $id -}} + {{- range seq 1 50 -}} + {{- if in $usedIds $id -}} + {{- $id = printf "%s-%d" $base . -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $id -}} + {{- $usedIds = $usedIds | append $id -}} + {{- end -}} + {{- /* Add to sections array */ -}} {{- if and $id $title -}} {{- if $isH3 -}} From 501e77f2191fd8f3ee0c395f4bba50e902a56d72 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 14:31:07 +0100 Subject: [PATCH 7/9] DOC-6939 Make the JSON section transform safe to re-run The script consumes the content field and does not write it back, so running it twice over the same output was silently destructive: the second pass found no content and rewrote every content page as an empty index page, with no error and no warning. Normal builds are safe because the Makefile always chains it after a fresh hugo, but nothing stopped a re-run, and the failure mode is total loss of feed content. It cost two rebuilds during this branch. A file that has a page_type but no content has already been transformed, so it is now skipped, and the run reports how many it skipped rather than staying quiet about it. The obvious alternative -- writing content back so the script really is idempotent -- was rejected. Replacing content with sections and examples is the whole purpose of the transform, and keeping the original alongside them would roughly double the size of every page record and of the feed. Verified by building once and transforming twice. The second run reports 5,732 skipped and 0 transformed, and the output still holds 5,687 content pages, 46,348 sections and 29,319 examples, with content_hash verifying on all 5,687. Before this change the second run left zero content pages. Learned: the failure was silent because an already-transformed page is structurally valid input, just with no content, so the index-page branch accepted it happily Rejected: writing the content field back to make the script truly idempotent | replacing content with sections is the point of the transform, and keeping both would roughly double the feed size Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- build/transform_json_sections.ts | 34 ++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/build/transform_json_sections.ts b/build/transform_json_sections.ts index 590d9fa45e..736e4db60a 100644 --- a/build/transform_json_sections.ts +++ b/build/transform_json_sections.ts @@ -320,10 +320,20 @@ function computeContentHash( return createHash('sha256').update(content, 'utf-8').digest('hex'); } -function transformJsonFile(filePath: string, dryRun: boolean): boolean { +type TransformResult = 'transformed' | 'skipped' | 'error'; + +function transformJsonFile(filePath: string, dryRun: boolean): TransformResult { try { const fileContent = readFileSync(filePath, 'utf-8'); - const data: PageJsonInput = JSON.parse(fileContent); + const data: PageJsonInput & { page_type?: PageType } = JSON.parse(fileContent); + + // This script consumes `content` and does not write it back, so it is not + // safe to run twice over the same output: a second pass finds no content and + // would rewrite every content page as an empty index page. A file that has a + // page_type but no content has already been transformed, so leave it alone. + if (data.content === undefined && data.page_type !== undefined) { + return 'skipped'; + } // Remove content field from output const { content: rawContent, ...rest } = data; @@ -374,10 +384,10 @@ function transformJsonFile(filePath: string, dryRun: boolean): boolean { writeFileSync(filePath, JSON.stringify(newData, null, 2) + '\n'); } - return true; + return 'transformed'; } catch (err) { console.error(`Error processing ${filePath}:`, err); - return false; + return 'error'; } } @@ -406,14 +416,15 @@ function main() { let processed = 0; let transformed = 0; + let skipped = 0; if (singlePath) { // Process single file const fullPath = singlePath.startsWith('/') ? singlePath : join(process.cwd(), singlePath); console.log(`Processing single file: ${fullPath}`); - if (transformJsonFile(fullPath, dryRun)) { - transformed++; - } + const result = transformJsonFile(fullPath, dryRun); + if (result === 'transformed') transformed++; + if (result === 'skipped') skipped++; processed++; } else { // Process all JSON files @@ -421,9 +432,9 @@ function main() { for (const filePath of walkJsonFiles(publicDir)) { processed++; - if (transformJsonFile(filePath, dryRun)) { - transformed++; - } + const result = transformJsonFile(filePath, dryRun); + if (result === 'transformed') transformed++; + if (result === 'skipped') skipped++; // Progress indicator if (processed % 500 === 0) { @@ -433,6 +444,9 @@ function main() { } console.log(`\nDone! Processed ${processed} files, transformed ${transformed}.`); + if (skipped > 0) { + console.log(`Skipped ${skipped} already-transformed files. Re-run 'hugo' first if you meant to rebuild them.`); + } if (dryRun) { console.log('(Dry run - no files were modified)'); } From 317acdcab2497c30481f48f32bf4428b62e69421 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 14:44:52 +0100 Subject: [PATCH 8/9] DOC-6939 Let a real Overview heading keep the plain overview id Reverses a decision made deliberately in 3e648588b, which reasoned that where a page has intro prose and also an "## Overview" heading, the synthetic intro should keep the plain overview id and the real heading should take overview-1, because the established id mattered more to existing consumers than matching the anchor in that narrow case. That reasoning was wrong and the commit message for it should be read with this one. What it missed is that the mismatch is not only against Hugo. On the 166 affected pages Hugo renders the anchor overview for the real heading, and the metadata tableOfContents also gives overview to the real heading, so sections[] was the only one of the three naming the intro prose. A consumer following a url#overview citation landed on the intro instead of the section it asked for. That is a wrong answer rather than an id-stability preference, so it wins. Real heading ids are now reserved before the synthetic intro is named, so the heading keeps overview and the intro takes overview-1. Same id vocabulary, no new values, and the suffix now falls on the section that has no anchor on the page at all. All 166 pages check out: the intro holds the suffixed id and the plain overview is a real heading that Hugo has anchored. Also adds the + unescape that the post-table-children block was missing relative to the block above it. Unreachable today -- no built page contains that entity, no table-children column name or row contains a plus -- so this is closing an inconsistency, not fixing an observed defect. A version column reading "6.0+" would have exposed it. Duplicate section ids, duplicate example ids and brace leakage into titles all remain at zero, and content_hash still verifies for all 5,687 content pages. Worth knowing for the next verification: the check I first wrote for this used the section role to tell the synthetic intro from a real heading, which is meaningless, because assignRole gives a heading titled "Overview" the role overview too. It reported 165 of 166 still broken when the fix was already correct. Inspecting one page's section list in order settled it in seconds. Learned: the intro section holding the plain overview id mis-resolved url#overview citations on 166 pages, because Hugo's anchor and the metadata TOC both give that id to the real heading Constraint: reserve the ids of real headings before naming synthetic sections like the intro, or the intro takes an id that belongs to a heading and deep links resolve to the wrong section Rejected: keeping the plain overview id on the synthetic intro for consumer stability, as 3e648588b did | it breaks the real heading's deep link, and the intro has no page anchor to protect Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- build/transform_json_sections.ts | 13 +++++++++++-- layouts/partials/process-markdown-content.html | 5 ++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/build/transform_json_sections.ts b/build/transform_json_sections.ts index 736e4db60a..38721b71aa 100644 --- a/build/transform_json_sections.ts +++ b/build/transform_json_sections.ts @@ -245,6 +245,16 @@ function splitContentIntoSections(content: string): { sections: Section[]; examp return { sections: rawSections, examples: allExamples }; } + // Reserve the ids of the real headings BEFORE naming the synthetic intro section. + // A real "## Overview" heading has to keep the plain `overview` id, because that is + // the anchor Hugo renders for it and the id the metadata tableOfContents uses. If + // the intro took `overview` first, the heading would be pushed to `overview-1` and a + // #overview deep link would land on the intro instead of the heading. + const headings = matches.map(m => { + const parsed = parseHeading(m.title); + return { ...parsed, id: makeUniqueId(parsed.id, usedIds) }; + }); + // Extract text before first heading as intro/overview const introText = content.slice(0, matches[0].index).trim(); if (introText) { @@ -270,8 +280,7 @@ function splitContentIntoSections(content: string): { sections: Section[]; examp const headingEnd = newlinePos === -1 ? content.length : newlinePos + 1; const sectionText = content.slice(headingEnd, nextIndex).trim(); - const { title, id: baseId } = parseHeading(current.title); - const id = makeUniqueId(baseId, usedIds); + const { title, id } = headings[i]; const role = assignRole(title, rawSections.length); // Extract code blocks from section text diff --git a/layouts/partials/process-markdown-content.html b/layouts/partials/process-markdown-content.html index 6ec1a9726e..5e47e1eef7 100644 --- a/layouts/partials/process-markdown-content.html +++ b/layouts/partials/process-markdown-content.html @@ -80,13 +80,16 @@ otherwise discard the table -- taking the whole body of every release-notes index page with it. */ -}} {{- $content = partial "markdown-table-children.html" (dict "RawContent" $content "Page" .Page) -}} -{{- /* Unescape again, as Hugo re-escapes partial output */ -}} +{{- /* Unescape again, as Hugo re-escapes partial output. Must cover the same entities + as the block above, including +, or a "+" in a table cell (a version column + such as "6.0+", say) reaches the feed as a literal entity. */ -}} {{- $content = $content | replaceRE """ "\"" -}} {{- $content = $content | replaceRE """ "\"" -}} {{- $content = $content | replaceRE "'" "'" -}} {{- $content = $content | replaceRE "<" "<" -}} {{- $content = $content | replaceRE ">" ">" -}} {{- $content = $content | replaceRE "&" "&" -}} +{{- $content = $content | replaceRE "+" "+" -}} {{- /* Remove remaining shortcodes AFTER unescape (content now has literal < and >) */ -}} {{- /* Match non-greedily to the first ">}}": a [^>]* class is defeated by a ">" inside From 33353bf47fed1b186c0d91912b3496d10b7f541f Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Wed, 5 Aug 2026 16:15:46 +0100 Subject: [PATCH 9/9] DOC-6939 Keep code-block headings out of the TOC, and close a fence that hid three sections Two Bugbot findings on the TOC partial, plus a content bug that following the second one uncovered. The TOC was built from every ##/### line in the raw markdown, including lines inside fenced code blocks, while splitContentIntoSections skips those. A YAML comment reading "## the defaults are commented" was therefore published as a navigation entry on the context-retriever install page. The TOC now drops fenced blocks before matching headings, using the same pairing rule as the transform so the two agree on what counts as code. The id dedup loop was also bounded at an arbitrary 50, where the TypeScript side is unbounded. Not reachable today -- the largest same-base-id group on any page is 19 -- but an arbitrary cap fails by silently leaving duplicates. It is now bounded by the heading count, which a page can never exceed. The content bug is the more valuable find and it runs the opposite way to what Bugbot described. On integrate/redis-data-integration/installation/upgrade a fenced block inside a warning shortcode was never closed. Hugo does not mind, because the shortcode body ends and the HTML renders correctly with every heading present, so nothing looked wrong on the site. The transform pairs ``` marks naively, so it paired that opener with the next block's opener and treated two large spans as code, silently dropping three real sections from the feed: Upgrading a Kubernetes installation, Upgrading to RDI 1.8.0 or later from an earlier version, and Verifying the upgrade. That page went from 5 sections to its full 8 once the fence was closed. Order mattered here. Aligning the TOC with the transform's code-block rule while the source was still broken would have made the TOC drop those three headings too, so the two artifacts would have agreed by being equally wrong. The source is fixed first for that reason. TOC entries with no counterpart in sections[] fall from 60 to 53, duplicate TOC ids stay at zero, and the section-id and example-id invariants and content_hash verification are unchanged. My fence detection was wrong for the fourth time in this branch on the way here: of four headings it reported as sitting inside code blocks, three were not, all on this same RDI page, because an indented fence it cannot model flipped its state. Comparing the built TOC against the built sections[] -- artifact against artifact -- found the real one and the dropped sections that a source-parsing heuristic had missed entirely. Learned: a source fence that Hugo renders correctly can still break the feed, because Hugo closes it at the shortcode boundary while the transform pairs backtick marks naively -- so "the page looks right" is not evidence the feed is right Constraint: the TOC partial and splitContentIntoSections must use the same rule for what counts as a fenced code block, or their ids diverge again Directive: never bound the id dedup loop by a round number -- bound it by the heading count, since an arbitrary cap fails by leaving silent duplicates Gaps: 53 TOC entries still have no counterpart in sections[], now from a different defect -- the TOC's emphasis-stripping regexes mangle code-like headings, turning a Python signature into "class AsyncSearchIndex(schema, , redisurl=None)" Ticket: DOC-6939 Co-Authored-By: Claude Opus 5 (1M context) --- .../redis-data-integration/installation/upgrade.md | 1 + layouts/partials/toc-from-markdown.html | 13 +++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/content/integrate/redis-data-integration/installation/upgrade.md b/content/integrate/redis-data-integration/installation/upgrade.md index e4052e327b..da873958c0 100644 --- a/content/integrate/redis-data-integration/installation/upgrade.md +++ b/content/integrate/redis-data-integration/installation/upgrade.md @@ -103,6 +103,7 @@ After upgrading, manually set a unique cluster ID for one of the installations ( ```bash sudo nano /etc/rdi/rdi-sys-config.yaml + ``` {{< /warning >}} ## Upgrading a Kubernetes installation diff --git a/layouts/partials/toc-from-markdown.html b/layouts/partials/toc-from-markdown.html index ef4350f6a8..f59a6e6be5 100644 --- a/layouts/partials/toc-from-markdown.html +++ b/layouts/partials/toc-from-markdown.html @@ -19,6 +19,12 @@ does the same in build/transform_json_sections.ts. */ -}} {{- $usedIds := slice -}} +{{- /* Drop fenced code blocks before looking for headings, so a "## comment" inside a + code sample does not become a navigation entry. Uses the same simple pairing rule + as splitContentIntoSections in build/transform_json_sections.ts, so both agree on + what counts as code and their ids stay in step. */ -}} +{{- $content = $content | replaceRE "(?s)```.*?```" "" -}} + {{- /* Find all ## and ### headers in the raw markdown */ -}} {{- /* Pattern matches lines starting with ## or ### followed by space and title */ -}} {{- $headerPattern := `(?m)^(#{2,3}) +(.+)$` -}} @@ -79,10 +85,13 @@ {{- $id = $explicitId -}} {{- end -}} - {{- /* Disambiguate a repeated id with Hugo's own -1, -2 suffix convention */ -}} + {{- /* Disambiguate a repeated id with Hugo's own -1, -2 suffix convention. Bounded by + the heading count rather than an arbitrary number, since a page can never need + more suffixes than it has headings -- an arbitrary cap would silently leave + duplicates on a page that exceeded it. */ -}} {{- if in $usedIds $id -}} {{- $base := $id -}} - {{- range seq 1 50 -}} + {{- range seq 1 (len $headerMatches) -}} {{- if in $usedIds $id -}} {{- $id = printf "%s-%d" $base . -}} {{- end -}}