From 9618231dfc8d6d63fa99a7d3fefbcbfe13cd004f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 24 Aug 2026 08:52:23 +0000 Subject: [PATCH] feat(docs): gate MDX docs on Crowdin-safe placeholders (#24540) Tracked as `DOCS-01` on twentyhq/core-team-issues#2784. ### Problem Crowdin parses `` in prose as a tag rather than literal text, so an angle bracket placeholder is dropped or mangled in every translated page. Curly braces `{foo}` survive the round trip. A `no-angle-bracket-placeholders` rule used to guard against this. It ran as `'error'` on `**/*.mdx` in the root `eslint.config.mjs` from #15751 (Nov 2025) until #18443 migrated us to oxlint. oxlint has no `eslint-plugin-mdx` equivalent, so the whole `**/*.mdx` block went with it and the rule stopped running. The file was carried into `twenty-oxlint-rules` wired to nothing, and #24535 removed it as dead code. So the guard has been silently off for about five months, not missing by design. This restores it in a form that runs without a JSX AST: a plain text scan, no new dependencies. ### Change - `scripts/lint-mdx.ts` scans the 204 English source pages and skips `l/` (the 2613 generated Crowdin files, not hand-editable). A violation there is only ever a symptom of one in the source. - The `lint` target was `{}`, an empty no-op, while the `docs-lint` CI job invoked `nx lint twenty-docs`. So the job has been passing without running anything. The target now runs the package's own `.oxlintrc.json` (nothing was running that either, so `scripts/*.ts` is linted for the first time) followed by the MDX scan. - A `test` target plus a vitest config for the package, wired into CI. 14 tests. ### On the backtick handling The scanner skips angle brackets inside code spans, since they are legitimate there. The first version did this with a running backtick parity count over the whole file, which is unsound: one unpaired backtick in prose flips parity and silently suppresses every finding after it in the file, while the run still prints clean. `docker-compose.mdx` was already in that state from line 210. Fixed in 54dff6e4: backtick runs are now paired within a line, by run length, which is how inline code actually delimits. Three tests pin it, all of which fail against the old counter. ### Verification ``` $ npx nx test twenty-docs Tests 14 passed (14) ``` End to end on the real CI command. Seeding two violations into `getting-started/introduction.mdx`: ``` $ npx nx lint twenty-docs getting-started/introduction.mdx:78:5 reads as a tag in Crowdin, use {workspace-slug} instead getting-started/introduction.mdx:78:34 reads as a tag in Crowdin, use {span} instead 2 angle bracket placeholder(s) found in 204 MDX files. nx lint exit=1 ``` Unseeded, the tree is clean: `No angle bracket placeholders in 204 MDX files.` To be clear about scope: the gate catches nothing in the tree today. Of the 83 angle brackets in the English source, 81 sit inside code spans and 2 are a legitimate `kbd` element. It is a preventive guard, plus the CI wiring fix above. The live corruption (a backslash before angle brackets inside inline code in the translated output, 494 across 156 files) is a `crowdin-normalizer` concern, not something a source-side gate can see, and is handled in a separate PR. ### Not in this PR `DOCS-01` also covered `mdx-component-newlines` (component tags sharing a line with prose). Deferring it, but not because the current occurrences are settled idiom: blame shows 137 of them were authored while that rule was live at `'error'` and only 68 after it went dark, so it was never really enforcing. Its opening-tag branch early-returns whenever the following text is adjacent, so a component wrapping text on one line never tripped it. Porting it faithfully buys nothing without redesigning what it checks, which is a separate call that stays on #2784. ``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">`` --------- Co-authored-by: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Co-authored-by: Abdul Rahman --- .github/workflows/ci-docs.yaml | 2 + packages/twenty-docs/package.json | 4 +- packages/twenty-docs/project.json | 19 +- .../scripts/__tests__/lint-mdx.spec.ts | 128 +++++++++ packages/twenty-docs/scripts/lint-mdx.ts | 266 ++++++++++++++++++ packages/twenty-docs/vitest.config.mts | 9 + yarn.lock | 2 + 7 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-docs/scripts/__tests__/lint-mdx.spec.ts create mode 100644 packages/twenty-docs/scripts/lint-mdx.ts create mode 100644 packages/twenty-docs/vitest.config.mts diff --git a/.github/workflows/ci-docs.yaml b/.github/workflows/ci-docs.yaml index 0c5d28e5629..ade8b2fd9f9 100644 --- a/.github/workflows/ci-docs.yaml +++ b/.github/workflows/ci-docs.yaml @@ -39,3 +39,5 @@ jobs: - name: Docs / Lint run: npx nx lint twenty-docs + - name: Docs / Test + run: npx nx test twenty-docs diff --git a/packages/twenty-docs/package.json b/packages/twenty-docs/package.json index 84ff14e3ffc..92460290d92 100644 --- a/packages/twenty-docs/package.json +++ b/packages/twenty-docs/package.json @@ -14,7 +14,9 @@ "mintlify": "^4.2.594" }, "devDependencies": { - "twenty-shared": "workspace:*" + "tsx": "^4.19.3", + "twenty-shared": "workspace:*", + "vitest": "^4.1.0" }, "engines": { "node": "^24.5.0", diff --git a/packages/twenty-docs/project.json b/packages/twenty-docs/project.json index b3d7b63bfec..91b15ce0875 100644 --- a/packages/twenty-docs/project.json +++ b/packages/twenty-docs/project.json @@ -19,7 +19,18 @@ "command": "mintlify validate" } }, - "lint": {}, + "lint": { + "executor": "nx:run-commands", + "cache": true, + "options": { + "cwd": "{projectRoot}", + "commands": [ + "npx oxlint -c .oxlintrc.json .", + "npx tsx scripts/lint-mdx.ts" + ], + "parallel": false + } + }, "fmt": { "executor": "nx:run-commands", "cache": true, @@ -33,6 +44,12 @@ "command": "prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata" } } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "npx vitest run --config {projectRoot}/vitest.config.mts" + } } } } diff --git a/packages/twenty-docs/scripts/__tests__/lint-mdx.spec.ts b/packages/twenty-docs/scripts/__tests__/lint-mdx.spec.ts new file mode 100644 index 00000000000..8083b608b5c --- /dev/null +++ b/packages/twenty-docs/scripts/__tests__/lint-mdx.spec.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import { findAngleBracketPlaceholders } from '../lint-mdx'; + +describe('findAngleBracketPlaceholders', () => { + it('flags a placeholder in prose', () => { + const violations = findAngleBracketPlaceholders( + 'Set the header to before calling.', + ); + + expect(violations).toEqual([ + { line: 1, column: 19, text: '', name: 'your-api-key' }, + ]); + }); + + it('reports the line and column of a placeholder further down the page', () => { + const violations = findAngleBracketPlaceholders( + 'intro\n\nuse here', + ); + + expect(violations).toEqual([ + { line: 3, column: 5, text: '', name: 'workspace-id' }, + ]); + }); + + it('allows HTML elements', () => { + expect( + findAngleBracketPlaceholders('press K to search'), + ).toEqual([]); + }); + + it('allows closing tags', () => { + expect(findAngleBracketPlaceholders('
x
')).toEqual([]); + }); + + it('ignores placeholders inside a fenced code block', () => { + expect( + findAngleBracketPlaceholders( + 'before\n\n```bash\ncurl -H "key: "\n```\n\nafter', + ), + ).toEqual([]); + }); + + it('ignores placeholders inside inline code', () => { + expect(findAngleBracketPlaceholders('run `deploy ` first')).toEqual( + [], + ); + }); + + it('still flags a placeholder after a closed inline code span', () => { + const violations = findAngleBracketPlaceholders( + 'run `deploy` against now', + ); + + expect(violations).toHaveLength(1); + expect(violations[0].name).toBe('env'); + }); + + it('flags an HTML element name used as a host placeholder', () => { + const violations = findAngleBracketPlaceholders( + 'open https://.twenty.com', + ); + + expect(violations).toHaveLength(1); + expect(violations[0].name).toBe('code'); + }); + + it('flags a placeholder inside a path segment', () => { + const violations = findAngleBracketPlaceholders( + 'edit packages//README.md', + ); + + expect(violations).toHaveLength(1); + expect(violations[0].name).toBe('package-name'); + }); + + it('does not flag single letter or capitalised tags', () => { + expect( + findAngleBracketPlaceholders('link and body'), + ).toEqual([]); + }); + + it('reports every placeholder on a line', () => { + const violations = findAngleBracketPlaceholders( + ' and ', + ); + + expect(violations.map((violation) => violation.name)).toEqual([ + 'first-id', + 'second-id', + ]); + }); + it('is not blinded by an unpaired backtick earlier in the file', () => { + const violations = findAngleBracketPlaceholders( + 'a stray ` backtick\n\nthen use here', + ); + + expect(violations).toHaveLength(1); + expect(violations[0].name).toBe('workspace-id'); + }); + + it('pairs backtick runs by length', () => { + expect( + findAngleBracketPlaceholders('``code with ` tick and ``'), + ).toEqual([]); + }); + + it('does not treat a backtick on a previous line as opening a span', () => { + const violations = findAngleBracketPlaceholders( + 'ends with a tick `\nnext line has ', + ); + + expect(violations).toHaveLength(1); + expect(violations[0].name).toBe('api-key'); + }); + + it('keeps a four-backtick block that shows a triple-backtick example as code', () => { + expect( + findAngleBracketPlaceholders('````md\n```\n\n```\n````'), + ).toEqual([]); + }); + + it('closes a block on a longer fence than the one that opened it', () => { + expect(findAngleBracketPlaceholders('```ini\nKEY=\n````')).toEqual( + [], + ); + }); +}); diff --git a/packages/twenty-docs/scripts/lint-mdx.ts b/packages/twenty-docs/scripts/lint-mdx.ts new file mode 100644 index 00000000000..ff78f5fe981 --- /dev/null +++ b/packages/twenty-docs/scripts/lint-mdx.ts @@ -0,0 +1,266 @@ +import fs from 'fs'; +import path from 'path'; + +// Crowdin parses `` in prose as a tag rather than literal text, so an angle +// bracket placeholder is either dropped or mangled in every translated page. +// Curly braces `{foo}` survive the round trip. + +const DOCS_ROOT = path.resolve(__dirname, '..'); + +const IGNORED_DIRECTORIES = ['node_modules', 'l', 'images', 'scripts']; + +const HTML_ELEMENTS = [ + 'abbr', + 'article', + 'aside', + 'blockquote', + 'br', + 'button', + 'cite', + 'code', + 'dd', + 'del', + 'details', + 'div', + 'dl', + 'dt', + 'em', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'hr', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'label', + 'li', + 'main', + 'mark', + 'nav', + 'ol', + 'option', + 'picture', + 'pre', + 'samp', + 'section', + 'select', + 'small', + 'source', + 'span', + 'strong', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'tr', + 'ul', + 'var', + 'video', +]; + +const PLACEHOLDER_PATTERN = /<([a-z][a-z0-9_-]+)>/g; + +const URL_PREFIX_PATTERN = /https?:\/\/$/; + +export type MdxViolation = { + line: number; + column: number; + text: string; + name: string; +}; + +type Range = { + start: number; + end: number; +}; + +const FENCE_LINE_PATTERN = /^\s*(`{3,})/; + +// A fence closes only on a run at least as long as the one that opened it, so a +// block opened with ``` is not closed by the first ``` inside a ```` example. +const getFencedCodeRanges = (text: string): Range[] => { + const ranges: Range[] = []; + const lines = text.split('\n'); + + let lineStart = 0; + let openStart: number | null = null; + let openLength = 0; + + for (const line of lines) { + const fence = FENCE_LINE_PATTERN.exec(line); + + if (openStart === null) { + if (fence) { + openStart = lineStart; + openLength = fence[1].length; + } + } else if ( + fence && + fence[1].length >= openLength && + line.slice(fence[0].length).trim() === '' + ) { + ranges.push({ start: openStart, end: lineStart + line.length }); + openStart = null; + } + + lineStart += line.length + 1; + } + + return ranges; +}; + +const isInsideRange = (position: number, ranges: Range[]) => + ranges.some((range) => position >= range.start && position < range.end); + +// Backtick runs are paired within a line, never across one. A running parity +// counter would let a single unpaired backtick silently suppress every finding +// in the rest of the file. +const getInlineCodeRanges = (text: string, fencedRanges: Range[]): Range[] => { + const ranges: Range[] = []; + let lineStart = 0; + + for (const line of text.split('\n')) { + const runs: { start: number; length: number }[] = []; + + for (const match of line.matchAll(/`+/g)) { + const start = lineStart + match.index; + + if (!isInsideRange(start, fencedRanges)) { + runs.push({ start, length: match[0].length }); + } + } + + const unclosed: typeof runs = []; + + for (const run of runs) { + const openerIndex = unclosed.findIndex( + (candidate) => candidate.length === run.length, + ); + + if (openerIndex === -1) { + unclosed.push(run); + continue; + } + + ranges.push({ + start: unclosed[openerIndex].start, + end: run.start + run.length, + }); + unclosed.splice(0, openerIndex + 1); + } + + lineStart += line.length + 1; + } + + return ranges; +}; + +const getLineAndColumn = (text: string, position: number) => { + const precedingText = text.slice(0, position); + const lines = precedingText.split('\n'); + + return { + line: lines.length, + column: lines[lines.length - 1].length + 1, + }; +}; + +export const findAngleBracketPlaceholders = (text: string): MdxViolation[] => { + const fencedRanges = getFencedCodeRanges(text); + const inlineCodeRanges = getInlineCodeRanges(text, fencedRanges); + const violations: MdxViolation[] = []; + + for (const match of text.matchAll(PLACEHOLDER_PATTERN)) { + const name = match[1]; + const position = match.index; + + const isUrlPlaceholder = URL_PREFIX_PATTERN.test(text.slice(0, position)); + + if (HTML_ELEMENTS.includes(name) && !isUrlPlaceholder) { + continue; + } + + if ( + isInsideRange(position, fencedRanges) || + isInsideRange(position, inlineCodeRanges) + ) { + continue; + } + + violations.push({ + ...getLineAndColumn(text, position), + text: match[0], + name, + }); + } + + return violations; +}; + +const collectMdxFiles = (directory: string, collected: string[] = []) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (IGNORED_DIRECTORIES.includes(entry.name)) { + continue; + } + + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + collectMdxFiles(entryPath, collected); + } else if (entry.name.endsWith('.mdx')) { + collected.push(entryPath); + } + } + + return collected; +}; + +const main = () => { + const files = collectMdxFiles(DOCS_ROOT); + let violationCount = 0; + + for (const file of files) { + const violations = findAngleBracketPlaceholders( + fs.readFileSync(file, 'utf8'), + ); + + for (const violation of violations) { + const relativePath = path.relative(DOCS_ROOT, file); + + console.error( + `${relativePath}:${violation.line}:${violation.column} ${violation.text} reads as a tag in Crowdin, use {${violation.name}} instead`, + ); + } + + violationCount += violations.length; + } + + if (violationCount > 0) { + console.error( + `\n${violationCount} angle bracket placeholder(s) found in ${files.length} MDX files.`, + ); + process.exit(1); + } + + console.log(`No angle bracket placeholders in ${files.length} MDX files.`); +}; + +if (require.main === module) { + main(); +} diff --git a/packages/twenty-docs/vitest.config.mts b/packages/twenty-docs/vitest.config.mts new file mode 100644 index 00000000000..765bb0a479c --- /dev/null +++ b/packages/twenty-docs/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + root: 'packages/twenty-docs', + include: ['scripts/**/*.spec.ts'], + globals: true, + }, +}); diff --git a/yarn.lock b/yarn.lock index 0544bb15df5..473a560cd7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -49756,7 +49756,9 @@ __metadata: resolution: "twenty-docs@workspace:packages/twenty-docs" dependencies: mintlify: "npm:^4.2.594" + tsx: "npm:^4.19.3" twenty-shared: "workspace:*" + vitest: "npm:^4.1.0" languageName: unknown linkType: soft