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
21 changes: 15 additions & 6 deletions .github/workflows/docs-links.yml → .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Docs Links
name: Docs

# ci.yml ignores docs paths, so docs-only PRs need their own lane. The gate
# builds the site and checks the rendered pages, so the rspress version
# ci.yml ignores docs paths, so docs-only PRs need their own lane. The links
# gate builds the site and checks the rendered pages, so the rspress version
# (website/package.json, the lockfile) and config re-run it as well.
on:
pull_request:
Expand All @@ -12,12 +12,14 @@ on:
- 'README.md'
- 'scripts/check-doc-links.ts'
- 'scripts/__tests__/check-doc-links.test.ts'
- 'scripts/check-doc-prose.ts'
- 'scripts/__tests__/check-doc-prose.test.ts'
- 'scripts/check-affected/**'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/actions/run-gate/action.yml'
- '.github/actions/setup-node-pnpm/action.yml'
- '.github/workflows/docs-links.yml'
- '.github/workflows/docs.yml'
push:
branches:
- main
Expand All @@ -28,19 +30,21 @@ on:
- 'README.md'
- 'scripts/check-doc-links.ts'
- 'scripts/__tests__/check-doc-links.test.ts'
- 'scripts/check-doc-prose.ts'
- 'scripts/__tests__/check-doc-prose.test.ts'
- 'scripts/check-affected/**'
- 'package.json'
- 'pnpm-lock.yaml'
- '.github/actions/run-gate/action.yml'
- '.github/actions/setup-node-pnpm/action.yml'
- '.github/workflows/docs-links.yml'
- '.github/workflows/docs.yml'

permissions:
contents: read

jobs:
check:
name: Internal links and heading anchors
name: Prose, internal links, and heading anchors
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
Expand All @@ -50,6 +54,11 @@ jobs:
- name: Setup Node and pnpm
uses: ./.github/actions/setup-node-pnpm

# Reads the Markdown sources only, so it fails before the site build.
- name: Check docs prose
uses: ./.github/actions/run-gate
with: { gate: docs-prose }

# Builds the site (root install covers the website workspace), then checks it.
- name: Check docs links
uses: ./.github/actions/run-gate
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@
"check:package": "node --experimental-strip-types scripts/check-package.ts",
"check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts",
"check:doc-links": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/check-doc-links.test.ts && pnpm --dir website build && node --experimental-strip-types scripts/check-doc-links.ts",
"check:doc-prose": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/check-doc-prose.test.ts && node --experimental-strip-types scripts/check-doc-prose.ts",
"check:agent-guidance": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/agent-guidance-contract.test.ts",
"check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts",
"check:daemon-wire-compat": "node --experimental-strip-types scripts/wire-compat/run.ts",
Expand All @@ -174,7 +175,7 @@
"check:quick": "pnpm lint && pnpm typecheck",
"sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs",
"check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check",
"check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm check:doc-links && pnpm build && pnpm check:bundle-owner-files && pnpm check:package",
"check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:gate-manifest:test && pnpm check:gate-manifest && pnpm check:production-exports && pnpm check:tmpdir-leaks:test && pnpm check:xctest-selection && pnpm check:packaged-runner-swift && pnpm check:mcp-metadata && pnpm check:doc-prose && pnpm check:doc-links && pnpm build && pnpm check:bundle-owner-files && pnpm check:package",
"check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
"prepack": "npm_config_dry_run=false pnpm release:prepare",
Expand Down
117 changes: 117 additions & 0 deletions scripts/__tests__/check-doc-prose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { checkDocProse, PROSE_RULES } from '../check-doc-prose.ts';

function hits(text: string): string[] {
return checkDocProse('page.md', text).map(
({ line, column, word }) => `${line}:${column} ${word}`,
);
}

test('banned words match case-insensitively as whole words, with line and column', () => {
assert.deepEqual(hits('Simply run it.\nIt is Just fine, and EASY.'), [
'1:1 simply',
'2:7 just',
'2:22 easy',
]);
assert.deepEqual(hits('Justify the adjustment; robustness and uneasy are fine.'), []);
});

test('phrases match across any run of whitespace', () => {
assert.deepEqual(hits('Support is coming soon. As of now it is newly added.'), [
'1:12 coming soon',
'1:26 as of now',
'1:42 newly added',
]);
});

test('bare new, now, and current are allowed', () => {
assert.deepEqual(hits('The new session is now the current one.'), []);
});

test('each violation names the file, position, matched text, and reason', () => {
const [violation] = checkDocProse('README.md', 'It is robust.');
assert.equal(
violation?.message,
'README.md:1:7: "robust" — marketing adjective: state what the feature does instead',
);
});

test('every rule is a lowercase word or phrase with a reason', () => {
for (const { word, reason } of PROSE_RULES) {
assert.match(word, /^[a-z]+(?: [a-z]+)*$/);
assert.ok(reason.length > 0, word);
}
});

test('YAML frontmatter at the top is skipped, but a later thematic break is prose', () => {
assert.deepEqual(hits('---\ntitle: Just a page\n---\nText.\n\n---\n\nSimply.'), ['8:1 simply']);
});

test('fenced code is skipped until a matching closer of at least the opening length', () => {
const text = [
'````md',
'just',
'```',
'still code: simply',
'````',
'~~~',
'easy',
'~~~~',
'Prose: obviously.',
].join('\n');
assert.deepEqual(hits(text), ['9:8 obviously']);
});

test('a backtick fence is not closed by tildes', () => {
assert.deepEqual(hits('```\n~~~\njust\n```\nJust.'), ['5:1 just']);
});

test('a fence inside a blockquote is skipped through its quoted closer', () => {
assert.deepEqual(hits('> ```sh\n> just build\n> ```\n> Simply.'), ['4:3 simply']);
});

test('a fence inside an HTML comment does not swallow the prose after the comment', () => {
assert.deepEqual(hits('<!--\n```\n-->\nJust.'), ['4:1 just']);
});

test('inline code spans are skipped, matching backtick run lengths', () => {
assert.deepEqual(hits('Run `just build` or ``a ` just`` then just go.'), ['1:39 just']);
});

test('an inline code span that wraps across lines is skipped', () => {
assert.deepEqual(hits('Run `just\nsimply` here.'), []);
});

test('a stray backtick does not mask past the end of its paragraph', () => {
assert.deepEqual(hits('A ` tick.\n\nJust `x`.'), ['3:1 just']);
});

test('a phrase wrapped across lines matches at its first word', () => {
assert.deepEqual(hits('Support is coming\nsoon.'), ['1:12 coming soon']);
});

test('HTML tags, comments, link destinations, and bare URLs are skipped', () => {
const text = [
'<Tab label="easy">Easy</Tab>',
'See [the guide](./just-start.md) and ![logo](robust.svg).',
'Visit https://example.com/simply/easy now.',
'Before <!-- just a note --> after.',
'<!-- spans',
'simply',
'lines --> robust',
].join('\n');
assert.deepEqual(hits(text), ['1:19 easy', '7:11 robust']);
});

test('link text is prose', () => {
assert.deepEqual(hits('[Simply start](./start.md)'), ['1:2 simply']);
});

test('CRLF line endings are scanned like LF', () => {
const text = ['---', 'title: Simply the best', '---', '', 'You just run it.', ''].join('\r\n');
assert.deepEqual(
checkDocProse('page.md', text).map(({ line, word }) => `${line}:${word}`),
['5:just'],
);
});
1 change: 1 addition & 0 deletions scripts/check-affected/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const CHECK_CATALOG: readonly CheckSpec[] = [
gate('fixture-fallback', 'Fixture-app cache-failure fallback', 'test:fixture-fallback'),
gate('command-docs', 'Command reference doc coverage', 'check:command-docs'),
gate('docs-links', 'Docs internal links and heading anchors', 'check:doc-links'),
gate('docs-prose', 'Docs filler, marketing, and time-relative words', 'check:doc-prose'),
gate('agent-guidance', 'Agent guidance ownership and context budgets', 'check:agent-guidance'),
// Parses ios.yml and the Swift sources; no Xcode, no simulator, so it runs anywhere.
gate(
Expand Down
15 changes: 10 additions & 5 deletions scripts/check-affected/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,20 +205,25 @@ test('docs-only change selects no checks and records the docs paths', () => {
assert.equal(result.docsOnlyPaths.length, 3);
});

test('user docs pages and README select the docs-links check', () => {
test('user docs pages and README select the docs-links and docs-prose checks', () => {
for (const file of [
'README.md',
'website/docs/index.md',
'website/docs/docs/sessions.md',
'website/docs/404.mdx',
'website/package.json',
'website/rspress.config.ts',
]) {
const result = plan([file]);
assert.deepEqual(result.checks, ['docs-links'], file);
assert.deepEqual(result.checks, ['docs-links', 'docs-prose'], file);
assert.deepEqual(result.docsOnlyPaths, []);
}
assert.deepEqual(plan(['website/docs/docs/commands.md']).checks, ['command-docs', 'docs-links']);
for (const file of ['website/package.json', 'website/rspress.config.ts']) {
assert.deepEqual(plan([file]).checks, ['docs-links'], file);
}
assert.deepEqual(plan(['website/docs/docs/commands.md']).checks, [
'command-docs',
'docs-links',
'docs-prose',
]);
assert.deepEqual(plan(['website/docs/public/logo.svg']).checks, []);
});

Expand Down
28 changes: 18 additions & 10 deletions scripts/check-affected/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const ALL_CHECKS = [
'fixture-fallback',
'command-docs',
'docs-links',
'docs-prose',
'agent-guidance',
'xctest-selection',
'packaged-runner-swift',
Expand Down Expand Up @@ -543,16 +544,13 @@ function isAgentGuidance(file: string): boolean {
return AGENT_GUIDANCE.has(file) || file.startsWith('docs/agents/');
}

// The pages and README whose internal links and heading anchors `check:doc-links` resolves, and
// the website manifest and config that decide what the built site renders.
const DOC_LINKS_INPUTS = new Set([
'README.md',
'website/package.json',
'website/rspress.config.ts',
]);
// The user docs: README and the docs site pages. `check:doc-prose` reads them; `check:doc-links`
// resolves their links and also reruns when the website manifest or config that decide what the
// built site renders change.
const SITE_BUILD_INPUTS = new Set(['website/package.json', 'website/rspress.config.ts']);

function isLinkedDoc(file: string): boolean {
return DOC_LINKS_INPUTS.has(file) || (file.startsWith('website/docs/') && /\.mdx?$/.test(file));
function isUserDocPage(file: string): boolean {
return file === 'README.md' || (file.startsWith('website/docs/') && /\.mdx?$/.test(file));
}

const docsOwnership: OwnershipRule = ({ file }) => {
Expand All @@ -577,7 +575,7 @@ const docsOwnership: OwnershipRule = ({ file }) => {
),
);
}
if (isLinkedDoc(file)) {
if (isUserDocPage(file) || SITE_BUILD_INPUTS.has(file)) {
selections.push(
reason(
'docs-links',
Expand All @@ -587,6 +585,16 @@ const docsOwnership: OwnershipRule = ({ file }) => {
),
);
}
if (isUserDocPage(file)) {
selections.push(
reason(
'docs-prose',
file,
'own:docs-prose',
'user docs avoid filler, marketing, and time-relative words',
),
);
}
return selections;
};

Expand Down
Loading
Loading