diff --git a/apps/docs/content/docs/docs/installation.mdx b/apps/docs/content/docs/docs/installation.mdx index 21ac8330..6bfec90a 100644 --- a/apps/docs/content/docs/docs/installation.mdx +++ b/apps/docs/content/docs/docs/installation.mdx @@ -51,6 +51,6 @@ must use a specific mode. Without it, Luke UI follows the system preference. Understand how the theme root, identity, and tokens fit together. - Browse the components available to developers. + Browse the available components. diff --git a/apps/docs/content/docs/docs/layout.mdx b/apps/docs/content/docs/docs/layout.mdx index 79bff806..057f43ec 100644 --- a/apps/docs/content/docs/docs/layout.mdx +++ b/apps/docs/content/docs/docs/layout.mdx @@ -77,9 +77,9 @@ content can adapt without a breakpoint. ## Spacing and sizing -Padding, gap, and margin accept `0` plus value-based spacing keys such as `sp16` and `sp24`. Each -spacing key matches its pixel value, so `sp16` is 16px. Margin also accepts `auto`. Sizing and -grid-placement properties accept their CSS values. +Padding, gap, and margin accept `0` plus value-based spacing keys such as `sp16` and `sp24`. The +number in each key names its step on the scale, so `sp24` is one step larger than `sp16`. Margin +also accepts `auto`. Sizing and grid-placement properties accept their CSS values. Use logical properties such as `paddingInline`, `marginBlockStart`, and `maxInlineSize`. They work in both writing directions without a second layout rule. diff --git a/apps/docs/content/docs/docs/react-hook-form.mdx b/apps/docs/content/docs/docs/react-hook-form.mdx index b3d709d9..2db19a31 100644 --- a/apps/docs/content/docs/docs/react-hook-form.mdx +++ b/apps/docs/content/docs/docs/react-hook-form.mdx @@ -68,7 +68,7 @@ event before the library can run. ## Focus the first invalid field React Hook Form focuses the first invalid control after a failed submission, using the ref each -field registered. A field that never receives `field.ref` stays unfocused, and the person filling in +field registered. A field that never receives `field.ref` stays unfocused, and someone filling in the form gets an error message without being taken to it. Set `shouldFocusError: false` on `useForm` to turn this off. diff --git a/apps/docs/content/docs/docs/token-reference.mdx b/apps/docs/content/docs/docs/token-reference.mdx index a4c31055..5f97b0f7 100644 --- a/apps/docs/content/docs/docs/token-reference.mdx +++ b/apps/docs/content/docs/docs/token-reference.mdx @@ -29,8 +29,8 @@ value. ### Spacing -Spacing provides one scale for `gap`, `padding`, and `margin`. Each key matches its pixel value, so -`sp16` is 16px. +Spacing provides one scale for `gap`, `padding`, and `margin`. The number in each key names its step +on the scale, so `sp24` is one step larger than `sp16`. diff --git a/apps/docs/scripts/check-docs.ts b/apps/docs/scripts/check-docs.ts index 0b8d810b..ea7f0c11 100644 --- a/apps/docs/scripts/check-docs.ts +++ b/apps/docs/scripts/check-docs.ts @@ -13,14 +13,11 @@ import { import { findComponentPropsContractIssues } from '../src/lib/component-props-contract.js'; import { findComponentPropsTableTags } from '../src/lib/component-props-table-tags.js'; import { findMdxFiles } from '../src/lib/docs-mdx-files.js'; +import { extractProseForRules, findProseRuleLabels } from '../src/lib/docs-prose-rules.js'; import { exampleBlockSources } from '../src/lib/example-block-sources.js'; const MARKDOWN_H2_PATTERN = /^##\s+(.+?)\s*$/; -const IMPORT_EXPORT_PATTERN = /^(?:import|export)(?:[^A-Za-z0-9_]|$)/; -const FRONTMATTER_STRIP_PATTERN = /^---\n[\s\S]*?\n---\n/; const FENCED_CODE_PATTERN = /```[\s\S]*?```/g; -const INLINE_CODE_PATTERN = /`[^`]*`/g; -const JSX_TAG_CHAR_PATTERN = /[A-Za-z/]/; const PROPS_TABLE_TAG_OPEN_PATTERN = /> = { 'Related components': 'link to other components inline in prose instead', }; -const PROSE_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [ - { label: 'prose semicolon', pattern: /;/ }, - { label: '"assistive technologies"', pattern: /\bassistive technologies\b/i }, - { label: 'unspaced em dash', pattern: /[^ \n]—|—[^ \n]/ }, - { label: 'filler "simply"', pattern: /\bsimply\b/i }, - { label: 'filler "note that"', pattern: /\bnote that\b/i }, - { label: 'filler "it is important to"', pattern: /\bit is important to\b/i }, - { label: 'filler "allows you to"', pattern: /\ballows you to\b/i }, - { label: 'filler "enables you to"', pattern: /\benables you to\b/i }, - { label: 'filler "can be used to"', pattern: /\bcan be used to\b/i }, - { label: 'filler "seamless"', pattern: /\bseamless\b/i }, - { label: 'first-person "we"', pattern: /\bwe\b/i }, - { label: 'first-person "us"', pattern: /\bus\b/ }, - { label: 'first-person "let\'s"', pattern: /\blet's\b/i }, - { label: 'terminology "users"', pattern: /\busers\b/i }, - { label: 'terminology "the user"', pattern: /\bthe user\b/i }, - { label: 'terminology "a person"', pattern: /\ba person\b/i }, - { label: 'terminology "people"', pattern: /\bpeople\b/i }, -]; - export interface DocsCheckPaths { authoredDocsDir: string; componentsDir: string; @@ -318,14 +295,8 @@ function findProseIssues(paths: DocsCheckPaths): Array { } function matchProsePatterns(relativePath: string, source: string): Array { - const prose = stripForProse(source); - const issues: Array = []; - for (const { label, pattern } of PROSE_PATTERNS) { - if (pattern.test(prose)) { - issues.push(`${relativePath}: ${label}`); - } - } - return issues; + const prose = extractProseForRules(source); + return findProseRuleLabels(prose).map((label) => `${relativePath}: ${label}`); } function authoredMdxFiles(docsContentDir: string): Array { @@ -352,324 +323,10 @@ function lastH2Section(source: string, heading: string): string | undefined { return stripped.slice(start); } -/** - * Order matters here. Fenced/inline code and import/export statements are stripped first - * because they are lexically distinct from prose and must be removed as whole units — a - * partial strip (for example deleting only `{ users }` from an import and leaving - * `from './data';` behind) would leak a semicolon or identifier into later passes. MDX - * `{...}` expressions are stripped next, before tags, because an expression's brace matching - * naturally spans and consumes any tag nested inside it as one atomic replacement — for - * `{users.map((user) => ())}` the whole multi-line block collapses to - * a single space in one step. Stripping the inner `` tag first instead would leave - * behind a line containing only its one-space replacement, which a later expression scan - * could mistake for a blank line and abandon early. Tags are stripped last to clean up - * whatever markup (and any attribute expressions, e.g. `key={user.id}`) an expression on its - * own did not already remove. - */ -function stripForProse(source: string): string { - return stripJsxTags( - stripJsxExpressions( - stripInlineCode(stripImportLines(stripFencedCode(stripFrontmatter(source)))), - ), - ); -} - -/** - * Removes `import`/`export` statements in full, including continuation lines, so a - * multi-line destructure like `import {\n\tusers,\n} from './data';` does not leak its - * bound names into prose. Scanning tracks quote and brace state so the statement's `{...}` - * clause can span multiple lines; it terminates at a `;` or a newline seen while unquoted - * brace depth is zero. As a safety terminator, a blank line reached while brace depth is - * still open means the statement is malformed (or this isn't actually an import/export - * statement) — the leading keyword is then left as ordinary text rather than swallowing the - * rest of the document. - */ -function stripImportLines(source: string): string { - let result = ''; - let index = 0; - const length = source.length; - - while (index < length) { - const atLineStart = index === 0 || source[index - 1] === '\n'; - if (atLineStart && IMPORT_EXPORT_PATTERN.test(source.slice(index))) { - const end = skipImportExportStatement(source, index); - if (end !== undefined) { - index = end; - continue; - } - } - - result += source[index]; - index++; - } - - return result; -} - -/** - * Returns the index just past the end of the import/export statement starting at `start`, - * or `undefined` if it never reaches a well-formed end (see `stripImportLines`). - */ -function skipImportExportStatement(source: string, start: number): number | undefined { - const length = source.length; - let index = start; - let braceDepth = 0; - let quote: string | undefined; - - while (index < length) { - const char = source[index]; - - if (quote !== undefined) { - if (char === '\\') { - index += 2; - continue; - } - if (char === quote) quote = undefined; - index++; - continue; - } - - if (char === '"' || char === "'" || char === '`') { - quote = char; - index++; - continue; - } - - if (char === '{') { - braceDepth++; - index++; - continue; - } - - if (char === '}') { - braceDepth = Math.max(0, braceDepth - 1); - index++; - continue; - } - - if (char === ';' && braceDepth === 0) { - return index + 1; - } - - if (char === '\n') { - if (braceDepth === 0) { - return index; - } - if (isBlankLineAt(source, index + 1)) { - return undefined; - } - } - - index++; - } - - return braceDepth === 0 && quote === undefined ? length : undefined; -} - -function stripFrontmatter(source: string): string { - return source.replace(FRONTMATTER_STRIP_PATTERN, ''); -} - function stripFencedCode(source: string): string { return source.replace(FENCED_CODE_PATTERN, ''); } -function stripInlineCode(source: string): string { - return source.replace(INLINE_CODE_PATTERN, ''); -} - -/** - * Removes MDX/JSX tag syntax (opening, closing, and self-closing tags, including their - * attributes) while keeping the text content between tags, since that text is prose that - * still needs checking. Each stripped tag is replaced with a single space so removing it - * cannot fuse the words on either side into a false match. Attribute scanning tracks quote - * and brace state so `>` characters inside string or expression attribute values (for - * example `mode={{ a: 'b' }}` or `onClick={() => foo()}`) do not end the tag early. - */ -function stripJsxTags(source: string): string { - let result = ''; - let index = 0; - const length = source.length; - - while (index < length) { - const char = source[index]; - const next = source[index + 1]; - - if (char === '<' && next !== undefined && JSX_TAG_CHAR_PATTERN.test(next)) { - const end = skipJsxTag(source, index); - if (end !== undefined) { - index = end; - result += ' '; - continue; - } - } - - result += char; - index++; - } - - return result; -} - -/** - * Returns the index just past the end of the JSX tag starting at `start` (which must be `<`), - * or `undefined` if `start` is not actually the start of a tag. A blank line ends any JSX - * block in MDX, so a `<` that reaches a blank line before finding its closing `>` is prose - * (for example "values ' && braceDepth === 0) { - return index + 1; - } - - if (char === '\n' && braceDepth === 0 && isBlankLineAt(source, index + 1)) { - return undefined; - } - - index++; - } - - return undefined; -} - -/** True if a blank line (only spaces/tabs, then a newline or end of string) starts at `index`. */ -function isBlankLineAt(source: string, index: number): boolean { - let cursor = index; - while (cursor < source.length && (source[cursor] === ' ' || source[cursor] === '\t')) { - cursor++; - } - return cursor === source.length || source[cursor] === '\n'; -} - -/** - * Removes MDX/JSX `{...}` expressions — including flow/text expressions such as - * `{users.map(...)}` and comments such as `{/* note *\/}` — as a single atomic unit, brace - * to matching brace. This runs before `stripJsxTags` so a tag nested inside an expression - * (for example `{users.map((user) => ())}`) disappears along with the - * whole expression in one step, rather than being stripped on its own first and leaving - * behind a lone-space line that a later blank-line safety check could mistake for genuinely - * blank. Brace depth is tracked so nested expressions (`{{ a: 'b' }}`) balance correctly, - * quote state is tracked so a brace inside a string literal does not affect depth, and a - * blank line reached before the expression closes is treated as a safety terminator: the - * leading `{` is then left as ordinary prose rather than swallowing the rest of the - * document, since prose legitimately contains unmatched braces. - */ -function stripJsxExpressions(source: string): string { - let result = ''; - let index = 0; - const length = source.length; - - while (index < length) { - const char = source[index]; - - if (char === '{') { - const end = skipJsxExpression(source, index); - if (end !== undefined) { - index = end; - result += ' '; - continue; - } - } - - result += char; - index++; - } - - return result; -} - -/** - * Returns the index just past the end of the `{...}` expression starting at `start` (which - * must be `{`), or `undefined` if it never reaches a balanced close before a blank line or - * the end of the source. - */ -function skipJsxExpression(source: string, start: number): number | undefined { - const length = source.length; - let index = start + 1; - let braceDepth = 1; - let quote: string | undefined; - - while (index < length) { - const char = source[index]; - - if (quote !== undefined) { - if (char === '\\') { - index += 2; - continue; - } - if (char === quote) quote = undefined; - index++; - continue; - } - - if (char === '"' || char === "'" || char === '`') { - quote = char; - index++; - continue; - } - - if (char === '{') { - braceDepth++; - index++; - continue; - } - - if (char === '}') { - braceDepth--; - if (braceDepth === 0) { - return index + 1; - } - index++; - continue; - } - - if (char === '\n' && isBlankLineAt(source, index + 1)) { - return undefined; - } - - index++; - } - - return undefined; -} - function posixRelative(from: string, to: string): string { return relative(from, to).split('\\').join('/'); } diff --git a/apps/docs/src/lib/check-docs.test.ts b/apps/docs/src/lib/check-docs.test.ts index ba2e0334..51d28649 100644 --- a/apps/docs/src/lib/check-docs.test.ts +++ b/apps/docs/src/lib/check-docs.test.ts @@ -320,98 +320,6 @@ title: Attributes expect(findDocsIssues(paths)).toEqual(['docs/attributes.mdx: terminology "people"']); }); -test('strips multi-line tags and expression attributes without fusing surrounding words', () => { - const paths = createDocsFixture({ - authored: { - 'settings.mdx': `--- -title: Settings ---- - - - -Configure the workspace before you invite people to it. - -## Continue learning - - - - Choose a styling approach. - - -`, - }, - }); - - expect(findDocsIssues(paths)).toEqual(['docs/settings.mdx: terminology "people"']); -}); - -test('strips MDX expressions but still reports banned terms in prose and JSX child text', () => { - const paths = createDocsFixture({ - authored: { - 'expressions.mdx': `--- -title: Expressions ---- - -{users.map((user) => ( - -))} - -Invite people to explore the workspace. - -Ping people when the invite goes out. - -## Continue learning - - - - Choose a styling approach. - - -`, - }, - }); - - expect(findDocsIssues(paths)).toEqual(['docs/expressions.mdx: terminology "people"']); -}); - -test('strips multi-line import and export statements in full', () => { - const paths = createDocsFixture({ - authored: { - 'imports.mdx': `--- -title: Imports ---- - -import { - users, - somethingElse, -} from './data'; - -export { - users, - somethingElse, -}; - -Invite people to configure the settings. - -## Continue learning - - - - Choose a styling approach. - - -`, - }, - }); - - expect(findDocsIssues(paths)).toEqual(['docs/imports.mdx: terminology "people"']); -}); - test('reports prose patterns outside code and ignores them inside fences', () => { const paths = createDocsFixture({ authored: { diff --git a/apps/docs/src/lib/docs-prose-rules.test.ts b/apps/docs/src/lib/docs-prose-rules.test.ts new file mode 100644 index 00000000..bded7868 --- /dev/null +++ b/apps/docs/src/lib/docs-prose-rules.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'vite-plus/test'; +import { PROSE_RULES, extractProseForRules, findProseRuleLabels } from './docs-prose-rules.js'; + +test('every rule in PROSE_RULES has a hit case', () => { + const hitCaseByLabel: Record = { + '"assistive technologies"': 'The spinner is hidden from assistive technologies.', + 'filler "allows you to"': 'This allows you to configure it.', + 'filler "can be used to"': 'This can be used to configure it.', + 'filler "enables you to"': 'This enables you to configure it.', + 'filler "it is important to"': 'It is important to configure it.', + 'filler "note that"': 'Note that this changes behaviour.', + 'filler "seamless"': 'The integration is seamless.', + 'filler "simply"': 'Simply configure it.', + 'first-person "let\'s"': "Let's configure it.", + 'first-person "us"': 'This gives us control.', + 'first-person "we"': 'We configure it here.', + 'prose semicolon': 'Configure it; then run it.', + 'terminology "a person"': 'A person can configure it.', + 'terminology "people"': 'People can configure it.', + 'terminology "the user"': 'The user can configure it.', + 'terminology "users"': 'Users can configure it.', + 'unspaced em dash': 'Configure it—then run it.', + }; + + for (const rule of PROSE_RULES) { + const hitCase = hitCaseByLabel[rule.label]; + expect(hitCase, `no hit case declared for rule "${rule.label}"`).toBeDefined(); + expect(rule.pattern.test(hitCase ?? '')).toBe(true); + } +}); + +test('findProseRuleLabels reports every rule that hits, in PROSE_RULES order', () => { + expect(findProseRuleLabels('We configure it; simply run it.')).toEqual([ + 'prose semicolon', + 'filler "simply"', + 'first-person "we"', + ]); +}); + +test('findProseRuleLabels reports nothing for clean prose', () => { + expect(findProseRuleLabels('Configure it, then run it.')).toEqual([]); +}); + +test('an em dash with a space on each side is not reported', () => { + expect(findProseRuleLabels('Configure it — then run it.')).toEqual([]); +}); + +test('a hard-wrapped filler phrase is still caught across a line break', () => { + expect(findProseRuleLabels('Note\nthat this changes behaviour.')).toEqual(['filler "note that"']); +}); + +test('a hard-wrapped terminology phrase is still caught across a line break', () => { + expect(findProseRuleLabels('Configure it for the\nuser before you continue.')).toEqual([ + 'terminology "the user"', + ]); +}); + +test('"us" stays case-sensitive so it does not match inside another word', () => { + expect(findProseRuleLabels('This uses the default configuration.')).toEqual([]); +}); + +test('extractProseForRules strips an HTML entity so — is not read as an unspaced em dash', () => { + const prose = extractProseForRules('Use the — entity here.'); + expect(findProseRuleLabels(prose)).toEqual([]); +}); + +test('extractProseForRules strips inline code so a banned word inside it is not reported', () => { + const prose = extractProseForRules('Call `we.simply()` to configure it.'); + expect(findProseRuleLabels(prose)).toEqual([]); +}); + +test('extractProseForRules strips a JSX attribute value so a banned word inside it is not reported', () => { + const prose = extractProseForRules(''); + expect(findProseRuleLabels(prose)).toEqual([]); +}); + +test('extractProseForRules strips a single-line import line so a banned word in a binding name is not reported', () => { + const prose = extractProseForRules("import { we, us } from './data';\n\nConfigure it."); + expect(findProseRuleLabels(prose)).toEqual([]); +}); + +test('extractProseForRules strips fenced code so a banned word inside a code sample is not reported', () => { + const prose = extractProseForRules("```tsx\nconst users = ['we', 'us'];\n```\n\nConfigure it."); + expect(findProseRuleLabels(prose)).toEqual([]); +}); + +test('extractProseForRules strips frontmatter so a banned word in a title is not reported', () => { + const prose = extractProseForRules('---\ntitle: We simply note that\n---\n\nConfigure it.'); + expect(findProseRuleLabels(prose)).toEqual([]); +}); + +test('extractProseForRules leaves ordinary prose outside code, tags, and imports intact', () => { + const prose = extractProseForRules( + "import { Foo } from './foo';\n\nThis prose keeps its semicolon; it should still be reported.\n\n", + ); + expect(findProseRuleLabels(prose)).toEqual(['prose semicolon']); +}); diff --git a/apps/docs/src/lib/docs-prose-rules.ts b/apps/docs/src/lib/docs-prose-rules.ts new file mode 100644 index 00000000..215cb04f --- /dev/null +++ b/apps/docs/src/lib/docs-prose-rules.ts @@ -0,0 +1,92 @@ +// Deterministic, regex-based prose conventions. A mechanical rule belongs here. A rule that needs +// semantic judgement to apply correctly does not belong in an automated check at all; it stays +// documentation guidance instead. See extractProseForRules below for what "prose" means for these +// patterns. + +/** One mechanically checkable prose rule: a regex and the label its hit is reported under. */ +interface ProseRule { + readonly label: string; + readonly pattern: RegExp; +} + +export const PROSE_RULES: ReadonlyArray = [ + { label: 'prose semicolon', pattern: /;/ }, + { label: '"assistive technologies"', pattern: /\bassistive\s+technologies\b/i }, + { label: 'unspaced em dash', pattern: /[^ \n]—|—[^ \n]/ }, + { label: 'filler "simply"', pattern: /\bsimply\b/i }, + { label: 'filler "note that"', pattern: /\bnote\s+that\b/i }, + { label: 'filler "it is important to"', pattern: /\bit\s+is\s+important\s+to\b/i }, + { label: 'filler "allows you to"', pattern: /\ballows\s+you\s+to\b/i }, + { label: 'filler "enables you to"', pattern: /\benables\s+you\s+to\b/i }, + { label: 'filler "can be used to"', pattern: /\bcan\s+be\s+used\s+to\b/i }, + { label: 'filler "seamless"', pattern: /\bseamless\b/i }, + { label: 'first-person "we"', pattern: /\bwe\b/i }, + { label: 'first-person "us"', pattern: /\bus\b/ }, + { label: 'first-person "let\'s"', pattern: /\blet's\b/i }, + { label: 'terminology "users"', pattern: /\busers\b/i }, + { label: 'terminology "the user"', pattern: /\bthe\s+user\b/i }, + { label: 'terminology "a person"', pattern: /\ba\s+person\b/i }, + { label: 'terminology "people"', pattern: /\bpeople\b/i }, +]; + +/** Every rule in `PROSE_RULES` that matches `prose`, as `