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 `