Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions src/scripts/check-generated.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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,
DOCS_MANIFEST_SCHEMA,
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';

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<string, unknown>;
try {
frontmatter = parse(frontmatterMatch[1]) as Record<string, unknown>;
} 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('<doclink:')) {
fail(`Generated Markdown contains an unresolved documentation link: ${relativePath}.`);
}
}
}
}
Expand Down
13 changes: 10 additions & 3 deletions src/src/lib/docs/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { resolveGitHubToken } from '../github/token';
import { getReleaseIndex } from '../releases/runtime';
import { OWNER } from '../site';
import { convertGithubAlerts } from './alerts';
import { extractDescription, extractTitle, renderFrontmatter } from './frontmatter';
import {
extractDescription,
extractTitle,
normalizeDocumentHeadings,
renderFrontmatter,
} from './frontmatter';
import { extractHeadingSlugs, rewriteDocMarkdown, resolveDoclinks, slugifyDocFile } from './links';

export const DOCS_OUTPUT_DIR = resolve('src/content/docs');
Expand Down Expand Up @@ -298,15 +303,17 @@ async function buildPage(

const converted = convertGithubAlerts(raw.content);
const rewritten = rewriteDocMarkdown(converted, linkContext);
const content = resolveDoclinks(rewritten, slug);
const content = normalizeDocumentHeadings(resolveDoclinks(rewritten, slug));
// The project's landing page uses the catalogue display name, not the source
// README's first heading (which can repeat a "Purview.*" package name).
const title = slug === 'index' ? project.name : extractTitle(raw.content, sourceName(slug, raw));
const repo = getReleaseIndex().data.repos[project.repository];
const repoTags = repo?.topics ?? [];
const repoDescription = repo?.description?.trim() || null;
const description =
extractDescription(raw.content) || repoDescription || project.shortDescription;
(slug === 'index' ? project.shortDescription : extractDescription(raw.content)) ||
repoDescription ||
project.shortDescription;
const editSourcePath = raw.path.replace(/^\/+/, '');

const frontmatter: DocFrontmatter = {
Expand Down
74 changes: 60 additions & 14 deletions src/src/lib/docs/frontmatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,25 +31,71 @@ export function extractTitle(markdown: string, fileName: string): string {
.join(' ');
}

const MARKDOWN_SOURCE_PATTERN = /^```.*$/gm;
/** Keep one document title while preserving later source headings as sections. */
export function normalizeDocumentHeadings(markdown: string): string {
let foundTitle = false;
let fence: '```' | '~~~' | null = null;

return markdown
.split(/\r?\n/)
.map((line) => {
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(/<https?:\/\/[^>]+>/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 '';
}
Expand Down
56 changes: 56 additions & 0 deletions src/tests/unit/docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { convertGithubAlerts, parseGithubAlerts } from '../../src/lib/docs/alert
import {
extractDescription,
extractTitle,
normalizeDocumentHeadings,
renderFrontmatter,
} from '../../src/lib/docs/frontmatter';
import {
Expand Down Expand Up @@ -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',
Expand Down
Loading