diff --git a/.github/ia-governance.json b/.github/ia-governance.json index 5ae2a0e66..c3c6bd43e 100644 --- a/.github/ia-governance.json +++ b/.github/ia-governance.json @@ -44,7 +44,8 @@ ".github/scripts/**", ".github/workflows/ia-approval-gates.yml", ".github/workflows/docs-style-conformance.yml", - ".github/workflows/governance-docs-approval.yml" + ".github/workflows/governance-docs-approval.yml", + "scripts/lint-mdx.js" ] } }, diff --git a/.github/scripts/__tests__/check-ia-approvals.test.mjs b/.github/scripts/__tests__/check-ia-approvals.test.mjs index 0964d45b2..4447ad514 100644 --- a/.github/scripts/__tests__/check-ia-approvals.test.mjs +++ b/.github/scripts/__tests__/check-ia-approvals.test.mjs @@ -467,6 +467,7 @@ test("the CI gate protects the gate's own config and scripts", () => { ".github/workflows/ia-approval-gates.yml", ".github/workflows/docs-style-conformance.yml", ".github/CODEOWNERS", + "scripts/lint-mdx.js", ]) { assert.equal( activateByPaths(gate, [{ filename: p, status: "modified" }]).length, diff --git a/docs/content-guidelines.md b/docs/content-guidelines.md index a214b334d..a797e45f4 100644 --- a/docs/content-guidelines.md +++ b/docs/content-guidelines.md @@ -77,6 +77,7 @@ description: "Concise description explaining page purpose and value" - Include descriptive alt text for all images and diagrams - Use specific, actionable link text instead of "click here" - Ensure proper heading hierarchy starting with H2 +- Do not repeat the frontmatter page title as the first body heading. Mintlify already renders the title as the page H1; start with introductory content or a distinct H2 section. - Provide keyboard navigation considerations - Use sufficient color contrast in examples and visuals - Structure content for easy scanning with headers and lists diff --git a/docs/specifications/base-protocol/design-goals.mdx b/docs/specifications/base-protocol/design-goals.mdx index 99e86ddcf..45c9180dc 100644 --- a/docs/specifications/base-protocol/design-goals.mdx +++ b/docs/specifications/base-protocol/design-goals.mdx @@ -3,8 +3,6 @@ title: "Design Goals" description: "Design philosophy and lineage of the Base Chain protocol specification." --- -## Design Goals - Our aim is to design a protocol specification that is: - **Opinionated:** Simplicity through deliberate design choices. We identify the best solution and diff --git a/docs/specifications/builder-codes/overview.mdx b/docs/specifications/builder-codes/overview.mdx index 31c9106e7..819a013a9 100644 --- a/docs/specifications/builder-codes/overview.mdx +++ b/docs/specifications/builder-codes/overview.mdx @@ -4,8 +4,6 @@ sidebarTitle: "Overview" description: "Attribute onchain activity to your app, wallet or agent with Builder Codes." --- -## What Are Builder Codes - Base Builder Codes are an ERC-721 NFT collection where unique codes (e.g. "abc123") are minted to help identify builders onchain. Each code has associated metadata. Onchain metadata primarily includes a "payout address" where each code declares where potential rewards should be sent to. Offchain metadata includes more details about the app including its name and site. diff --git a/docs/specifications/transactions/transaction-finality.mdx b/docs/specifications/transactions/transaction-finality.mdx index 3f1ee411d..7143c8776 100644 --- a/docs/specifications/transactions/transaction-finality.mdx +++ b/docs/specifications/transactions/transaction-finality.mdx @@ -3,8 +3,6 @@ title: Transaction Finality description: Detailed information about transaction finality on Base. --- -## What Is Transaction Finality? - Finality refers to the point at which a transaction sent to Base becomes irreversible. This provides guarantees that the transaction will not be rolled back or lost. Finality works differently for normal transactions that modify Base L2 state than it does for transactions that withdraw funds from Base L2 to Ethereum L1. @@ -85,4 +83,3 @@ Transactions moving funds from Ethereum L1 to Base must be initiated on Ethereum No. The output proposal that was challenged is marked invalid, and any actions that used it's output root become invalid. Specifically, withdrawals from Base to L1 that proved against this output root must now prove against a different and valid one. - diff --git a/docs/style.css b/docs/style.css index b54c031bc..947a14e51 100644 --- a/docs/style.css +++ b/docs/style.css @@ -57,6 +57,11 @@ body { #content-area h3 { font-size: 1.25rem; margin-top: 1.5rem; margin-bottom: 0.5rem; } #content-area h4, #content-area h5, #content-area h6 { font-size: 1.1rem; margin-top: 1.25rem; margin-bottom: 0.5rem; } +/* Tighten the transition from the page description into either prose or a first section. + Mintlify's content container and heading margins otherwise stack into an oversized gap. */ +#content { margin-top: 1rem; } +#content > h2:first-child { margin-top: 0; } + /* Paragraphs */ p { margin-bottom: 1.25rem; } diff --git a/scripts/README.md b/scripts/README.md index 17c73bffa..cd01caf63 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -50,6 +50,7 @@ and the Naming Conventions section of [ia-guidelines.md](../docs/ia-guidelines.m | `title-case/nav-title` | Error | `docs.json` tab and group names use title case (`--check-nav`) | | `heading/no-h1` | Error | No H1 in body; the H1 comes from frontmatter `title` | | `heading/starts-at-h2` | Error | First body heading is H2 | +| `heading/redundant-page-title` | Error | First body heading does not repeat the frontmatter page title | | `heading/skipped-level` | Warning | No H2 → H4 jumps | | `heading/none` | Warning | At least one heading per page (SEO) | | `codeblock/language` | Error | Language specifier required | diff --git a/scripts/__tests__/lint-mdx.test.mjs b/scripts/__tests__/lint-mdx.test.mjs index 0ae62bd06..32ef339d3 100644 --- a/scripts/__tests__/lint-mdx.test.mjs +++ b/scripts/__tests__/lint-mdx.test.mjs @@ -12,6 +12,7 @@ const { checkCodeBlocks, checkAccessibility, checkHeadingStructure, + checkRedundantPageTitle, checkTitleCase, checkNavTitles, isLintablePage, @@ -36,6 +37,7 @@ test("the six spec-required rules are all errors", () => { "frontmatter/title", "frontmatter/description", "heading/starts-at-h2", + "heading/redundant-page-title", "a11y/link-text", "a11y/alt-text", "codeblock/filename-or-title", @@ -238,6 +240,33 @@ test("headings inside code fences are ignored", () => { assert.deepEqual(rulesOf(checkHeadingStructure("## Real\n\n```md Example\n# Not a heading\n```\n")), []); }); +test("the first body heading must not repeat the frontmatter title", () => { + const exact = '---\ntitle: "Design Goals"\ndescription: x\n---\n\n## Design Goals\n'; + assert.deepEqual(rulesOf(checkRedundantPageTitle(exact)), ["heading/redundant-page-title"]); +}); + +test("title comparisons ignore presentation and generic introductory wording", () => { + const punctuation = "---\ntitle: API & Events\ndescription: x\n---\n\n## `API` and Events!\n"; + const introductory = "---\ntitle: Transaction Finality\ndescription: x\n---\n\n## What Is Transaction Finality?\n"; + assert.deepEqual(rulesOf(checkRedundantPageTitle(punctuation)), ["heading/redundant-page-title"]); + assert.deepEqual(rulesOf(checkRedundantPageTitle(introductory)), ["heading/redundant-page-title"]); +}); + +test("the Base site qualifier does not make a repeated heading distinct", () => { + const src = "---\ntitle: Base Builder Codes\ndescription: x\n---\n\n## What Are Builder Codes\n"; + assert.deepEqual(rulesOf(checkRedundantPageTitle(src)), ["heading/redundant-page-title"]); +}); + +test("a related but narrower first heading is not treated as a duplicate", () => { + const src = "---\ntitle: Contract Addresses\ndescription: x\n---\n\n## L2 Contract Addresses\n"; + assert.deepEqual(checkRedundantPageTitle(src), []); +}); + +test("headings in frontmatter and code examples are skipped for redundancy", () => { + const src = "---\ntitle: Example\ndescription: '# Example'\n---\n\n```md Sample\n## Example\n```\n\n## Usage\n"; + assert.deepEqual(checkRedundantPageTitle(src), []); +}); + // --------------------------------------------------------------------------- // Scope // --------------------------------------------------------------------------- diff --git a/scripts/lint-mdx.js b/scripts/lint-mdx.js index cadcf1828..f4eb0e8c1 100755 --- a/scripts/lint-mdx.js +++ b/scripts/lint-mdx.js @@ -5,7 +5,7 @@ * * Deterministic checks for MDX files: * - Frontmatter validation - * - Heading structure + * - Heading structure and page-title redundancy * - Code block language, filename/title and long-block conventions * - Mintlify component syntax * - Internal link validation @@ -82,7 +82,7 @@ function isLintablePage(relPath) { /** * Every rule id, mapped to its severity. "error" fails the build; "warning" is advisory. * - * The six rules the CI conformance check enforces come from the Language & Style + * The blocking rules the CI conformance check enforces come from the Language & Style * Conformance section of the CI gates spec. `wrap` and `highlight` stay advisory because * content-guidelines.md phrases them conditionally ("use `wrap` to prevent horizontal * scrolling"), so they are recommendations rather than always-violations. @@ -95,6 +95,7 @@ const RULES = { // Headings -- hierarchy must start at H2, since the H1 comes from frontmatter title "heading/no-h1": "error", "heading/starts-at-h2": "error", + "heading/redundant-page-title": "error", "heading/skipped-level": "warning", "heading/none": "warning", // Title case @@ -281,6 +282,96 @@ function checkHeadingStructure(content, filePath) { return issues; } +/** + * Introductory wording that does not make a first heading meaningfully different from the + * page title. For example, "What Is Transaction Finality?" still repeats a page titled + * "Transaction Finality". + */ +const HEADING_INTRO_PATTERNS = [ + /^(?:an?\s+)?overview\s+(?:of\s+)?/, + /^(?:an?\s+)?introduction\s+to\s+/, + /^about\s+/, + /^understanding\s+(?:the\s+)?/, + /^what\s+(?:is|are)\s+/, +]; + +/** Plain lowercase words suitable for conservative title comparisons. */ +function normalizeHeadingText(value) { + return value + .replace(/<[^>]+>/g, " ") + .replace(/[`*_~]/g, "") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/&(?:amp;|and;)?/g, " and ") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +/** Remove generic lead-ins that only rephrase a title rather than narrow its subject. */ +function stripHeadingIntro(value) { + for (const pattern of HEADING_INTRO_PATTERNS) { + if (pattern.test(value)) return value.replace(pattern, "").trim(); + } + return value; +} + +/** Ignore the site-wide Base/Base Chain qualifier when the subject otherwise matches. */ +function stripBaseQualifier(value) { + return value.replace(/^base(?:\s+chain)?\s+/, "").trim(); +} + +/** Locate the first Markdown heading outside frontmatter and fenced examples. */ +function firstBodyHeading(content) { + const lines = content.split("\n"); + let inFrontmatter = lines[0] === "---"; + let inCodeBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (inFrontmatter) { + if (i > 0 && line === "---") inFrontmatter = false; + continue; + } + + if (/^(`{3,}|~{3,})/.test(line)) { + inCodeBlock = !inCodeBlock; + continue; + } + if (inCodeBlock) continue; + + const match = line.match(/^(#{1,6})\s+(.+?)(?:\s+#+)?\s*$/); + if (match) return { line: i + 1, level: match[1].length, text: match[2].trim() }; + } + + return null; +} + +/** + * Block a first body heading that restates the H1 Mintlify renders from frontmatter. + * + * This intentionally avoids broad fuzzy matching: a heading such as "L2 Contract Addresses" + * is a real subsection of a page titled "Contract Addresses". Normalizing presentation and a + * small set of generic lead-ins catches near-duplicates without conflating related subjects. + */ +function checkRedundantPageTitle(content, filePath) { + const title = extractFrontmatterTitle(content); + const heading = firstBodyHeading(content); + if (!title || !heading) return []; + + const normalizedTitle = stripBaseQualifier(stripHeadingIntro(normalizeHeadingText(title))); + const normalizedHeading = stripBaseQualifier(stripHeadingIntro(normalizeHeadingText(heading.text))); + if (!normalizedTitle || normalizedTitle !== normalizedHeading) return []; + + return [ + issue( + heading.line, + "heading/redundant-page-title", + `First body heading "${heading.text}" repeats the page title "${title}"; remove it or make it a distinct section` + ), + ]; +} + /** * Attribute tokens that may follow the language on a fence. Anything left over after these * are removed is treated as the filename or title. @@ -836,6 +927,7 @@ function lintFile(filePath) { ...checkFrontmatter(content, filePath), ...checkTitleCase(content, filePath), ...checkHeadingStructure(content, filePath), + ...checkRedundantPageTitle(content, filePath), ...checkCodeBlocks(content, filePath), ...checkMintlifyComponents(content, filePath), ...checkAccessibility(content, filePath), @@ -1037,6 +1129,7 @@ module.exports = { checkFrontmatter, checkTitleCase, checkHeadingStructure, + checkRedundantPageTitle, checkCodeBlocks, checkMintlifyComponents, checkAccessibility, @@ -1044,6 +1137,8 @@ module.exports = { checkNavTitles, titleCaseViolations, collectCodeBlocks, + firstBodyHeading, + normalizeHeadingText, changedLinesByFile, };