From b985243f0d5dde447a920fdb668eafdddebae904 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:16:03 +0000 Subject: [PATCH 1/2] build(deps): bump taiki-e/install-action in the actions group Bumps the actions group with 1 update: [taiki-e/install-action](https://github.com/taiki-e/install-action). Updates `taiki-e/install-action` from 2.87.15 to 2.87.20 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/4076c08d76dba979c11a7285295b0716c1d67908...9983c65e42da123ff25d1f78505eb6de315aa172) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.20 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 570a798..46e0635 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -45,7 +45,7 @@ jobs: - name: Install Just # taiki-e/install-action v2.87.13 - uses: taiki-e/install-action@4076c08d76dba979c11a7285295b0716c1d67908 + uses: taiki-e/install-action@9983c65e42da123ff25d1f78505eb6de315aa172 with: tool: just@1.58.0 From 4cc8d9b4e06a0409defe91623f559f9193de7210 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 28 Sep 2026 15:23:12 +0100 Subject: [PATCH 2/2] test: added tests for md! --- src/scripts/check-generated.ts | 58 ++++++++++++++++++++++++++ src/src/lib/docs/aggregate.ts | 13 ++++-- src/src/lib/docs/frontmatter.ts | 74 ++++++++++++++++++++++++++------- src/tests/unit/docs.test.ts | 56 +++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 17 deletions(-) diff --git a/src/scripts/check-generated.ts b/src/scripts/check-generated.ts index 70bd008..90698e3 100644 --- a/src/scripts/check-generated.ts +++ b/src/scripts/check-generated.ts @@ -1,6 +1,7 @@ import { glob } from 'fast-glob'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import { parse } from 'yaml'; import { DOCS_CACHE_DIR, @@ -8,6 +9,7 @@ import { DOCS_OUTPUT_DIR, readDocsManifest, } from '../src/lib/docs/aggregate'; +import { extractDescription } from '../src/lib/docs/frontmatter'; import { loadProjects } from '../src/lib/manifest/load'; import { isReleaseCache, readReleaseCache } from '../src/lib/releases/cache'; @@ -37,6 +39,24 @@ function fail(message: string): void { console.error(` ✗ ${message}`); } +function inspectMarkdownStructure(markdown: string): { + titleCount: number; + unclosedFence: boolean; +} { + let titleCount = 0; + let fence: '```' | '~~~' | null = null; + for (const line of markdown.split(/\r?\n/)) { + const fenceMatch = /^\s*(```|~~~)/.exec(line); + if (fenceMatch?.[1]) { + const marker = fenceMatch[1] as '```' | '~~~'; + fence = fence === marker ? null : (fence ?? marker); + } else if (!fence && /^#\s+\S/.test(line)) { + titleCount += 1; + } + } + return { titleCount, unclosedFence: fence !== null }; +} + function validateDocsManifest(): void { const file = join(DOCS_CACHE_DIR, 'index.json'); if (!existsSync(file)) { @@ -98,6 +118,44 @@ function validateDocsMirror(): void { 'Re-run `just data-sync` so the sidebar and content agree.', ); } + if (!page.endsWith('.md')) { + continue; + } + + const relativePath = `docs/${entry.name}/${page}`; + const markdown = readFileSync(join(mirrorRoot, entry.name, page), 'utf8'); + const frontmatterMatch = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*\r?\n([\s\S]*)$/.exec(markdown); + if (!frontmatterMatch?.[1] || frontmatterMatch[2] === undefined) { + fail(`Generated Markdown has invalid front matter: ${relativePath}.`); + continue; + } + + let frontmatter: Record; + try { + frontmatter = parse(frontmatterMatch[1]) as Record; + } catch { + fail(`Generated Markdown front matter is not valid YAML: ${relativePath}.`); + continue; + } + + const body = frontmatterMatch[2]; + const description = String(frontmatter.description ?? '').trim(); + if (!description || description === '---' || !/[A-Za-z0-9]/.test(description)) { + fail(`Generated Markdown has an unreadable description: ${relativePath}.`); + } + if (!extractDescription(body)) { + fail(`Generated Markdown has no readable prose: ${relativePath}.`); + } + const { titleCount, unclosedFence } = inspectMarkdownStructure(body); + if (titleCount !== 1) { + fail(`Generated Markdown must contain exactly one H1: ${relativePath} (${titleCount}).`); + } + if (unclosedFence) { + fail(`Generated Markdown has an unclosed fenced block: ${relativePath}.`); + } + if (body.includes(' { + const fenceMatch = /^(\s*)(```|~~~)/.exec(line); + if (fenceMatch?.[2]) { + const marker = fenceMatch[2] as '```' | '~~~'; + fence = fence === marker ? null : (fence ?? marker); + return line; + } + if (fence || !/^#\s+\S/.test(line)) { + return line; + } + if (!foundTitle) { + foundTitle = true; + return line; + } + return `#${line}`; + }) + .join('\n'); +} + +const FRONTMATTER_PATTERN = /^---\s*\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$)/; +const FENCED_CODE_PATTERN = /^(?:```|~~~)[^\r\n]*\r?\n[\s\S]*?^(?:```|~~~)\s*$/gm; + +function markdownParagraphToPlainText(block: string): string { + return block + .replace(/^>\s?/gm, '') + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1') + .replace(/]+>/g, '') + .replace(/<[^>]+>/g, ' ') + .replace(/[`*_~]/g, '') + .replace(/\\([\\`*_[\]{}()#+.!<>-])/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} + +function isProseParagraph(block: string): boolean { + const firstLine = block.split(/\r?\n/, 1)[0]?.trim() ?? ''; + return ( + firstLine !== '' && + !/^(?:#{1,6}\s|:::|---$|___$|\*\*\*$)/.test(firstLine) && + !/^>\s*\[![A-Z]+\]/i.test(firstLine) && + !/^(?:[-+*]|\d+[.)])\s+/.test(firstLine) && + !firstLine.startsWith('|') && + !/^\[[^\]]+\]:\s+/.test(firstLine) && + !/^<(?:div|table|details|picture|figure|img|!--)\b/i.test(firstLine) + ); +} /** Extract a short description from the first non-heading paragraph. */ export function extractDescription(markdown: string, maxLength = 160): string { - const withoutCodeBlocks = markdown.replace(MARKDOWN_SOURCE_PATTERN, ''); - const paragraphs = withoutCodeBlocks + const prose = markdown.replace(FRONTMATTER_PATTERN, '').replace(FENCED_CODE_PATTERN, ''); + const first = prose .split(/\n{2,}/) .map((block) => block.trim()) - .filter( - (block) => - block !== '' && !block.startsWith('#') && !block.startsWith('>') && !block.startsWith(':'), - ) - .map((block) => - block - .replace(/[`*_[\]()]/g, '') - .replace(/\s+/g, ' ') - .trim(), - ); - const first = paragraphs[0]; + .filter(isProseParagraph) + .map(markdownParagraphToPlainText) + .find((block) => /[A-Za-z0-9]/.test(block)); if (!first) { return ''; } diff --git a/src/tests/unit/docs.test.ts b/src/tests/unit/docs.test.ts index 358357d..83a4440 100644 --- a/src/tests/unit/docs.test.ts +++ b/src/tests/unit/docs.test.ts @@ -8,6 +8,7 @@ import { convertGithubAlerts, parseGithubAlerts } from '../../src/lib/docs/alert import { extractDescription, extractTitle, + normalizeDocumentHeadings, renderFrontmatter, } from '../../src/lib/docs/frontmatter'; import { @@ -66,6 +67,61 @@ describe('front matter', () => { ); }); + test('keeps link labels without merging their destinations into prose', () => { + expect( + extractDescription( + '# Release flow\n\nReleases use the shared [Purview.Build](https://github.com/purview-dev/build) pipeline.', + ), + ).toBe('Releases use the shared Purview.Build pipeline.'); + }); + + test('skips source front matter, thematic breaks, lists, and fenced code', () => { + const source = [ + '---', + 'title: POC-001', + '---', + '', + '# POC-001', + '', + '---', + '', + '- First acceptance criterion', + '- Second acceptance criterion', + '', + '```bash', + 'dotnet add package Example', + '```', + '', + 'This proof of concept validates the end-to-end workflow.', + ].join('\n'); + + expect(extractDescription(source)).toBe( + 'This proof of concept validates the end-to-end workflow.', + ); + }); + + test('uses readable alt text when an image is part of a prose paragraph', () => { + expect( + extractDescription('# Overview\n\nUse ![the dashboard](dashboard.png) to inspect runs.'), + ).toBe('Use the dashboard to inspect runs.'); + }); + + test('uses an introductory quote but skips GitHub alert callouts', () => { + expect(extractDescription('# Guide\n\n> A practical guide to reliable generators.')).toBe( + 'A practical guide to reliable generators.', + ); + expect( + extractDescription('# Guide\n\n> [!NOTE]\n> Read this first.\n\nThe guide starts here.'), + ).toBe('The guide starts here.'); + }); + + test('demotes additional top-level headings without touching fenced examples', () => { + const source = '# POC-001\n\n## Context\n\n# Outcome\n\n```md\n# Example\n```'; + expect(normalizeDocumentHeadings(source)).toBe( + '# POC-001\n\n## Context\n\n## Outcome\n\n```md\n# Example\n```', + ); + }); + test('renders deterministic front matter with a quoted date', () => { const frontmatter: DocFrontmatter = { title: 'Getting Started',