feat(docs): gate MDX docs on Crowdin-safe placeholders (#24540)

Tracked as `DOCS-01` on twentyhq/core-team-issues#2784.

### Problem

Crowdin parses `<foo>` 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    <workspace-slug> reads as a tag in Crowdin, use {workspace-slug} instead
getting-started/introduction.mdx:78:34   <span> 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.


<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/24540?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>

---------

Co-authored-by: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com>
Co-authored-by: Abdul Rahman <ar5438376@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-08-24 08:52:23 +00:00
1 parent 0c193d8bdf
commit 9618231dfc
7 files changed
+428 -2

No files matched your search

+2
View File
@@ -39,3 +39,5 @@ jobs:
- name: Docs / Lint
run: npx nx lint twenty-docs
- name: Docs / Test
run: npx nx test twenty-docs
+3 -1
View File
@@ -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",
+18 -1
View File
@@ -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"
}
}
}
}
@@ -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 <your-api-key> before calling.',
);
expect(violations).toEqual([
{ line: 1, column: 19, text: '<your-api-key>', name: 'your-api-key' },
]);
});
it('reports the line and column of a placeholder further down the page', () => {
const violations = findAngleBracketPlaceholders(
'intro\n\nuse <workspace-id> here',
);
expect(violations).toEqual([
{ line: 3, column: 5, text: '<workspace-id>', name: 'workspace-id' },
]);
});
it('allows HTML elements', () => {
expect(
findAngleBracketPlaceholders('press <kbd>K</kbd> to search'),
).toEqual([]);
});
it('allows closing tags', () => {
expect(findAngleBracketPlaceholders('<details>x</details>')).toEqual([]);
});
it('ignores placeholders inside a fenced code block', () => {
expect(
findAngleBracketPlaceholders(
'before\n\n```bash\ncurl -H "key: <token>"\n```\n\nafter',
),
).toEqual([]);
});
it('ignores placeholders inside inline code', () => {
expect(findAngleBracketPlaceholders('run `deploy <env>` first')).toEqual(
[],
);
});
it('still flags a placeholder after a closed inline code span', () => {
const violations = findAngleBracketPlaceholders(
'run `deploy` against <env> 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://<code>.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/<package-name>/README.md',
);
expect(violations).toHaveLength(1);
expect(violations[0].name).toBe('package-name');
});
it('does not flag single letter or capitalised tags', () => {
expect(
findAngleBracketPlaceholders('<a>link</a> and <Card>body</Card>'),
).toEqual([]);
});
it('reports every placeholder on a line', () => {
const violations = findAngleBracketPlaceholders(
'<first-id> and <second-id>',
);
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 <workspace-id> here',
);
expect(violations).toHaveLength(1);
expect(violations[0].name).toBe('workspace-id');
});
it('pairs backtick runs by length', () => {
expect(
findAngleBracketPlaceholders('``code with ` tick and <env>``'),
).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 <api-key>',
);
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<token>\n```\n````'),
).toEqual([]);
});
it('closes a block on a longer fence than the one that opened it', () => {
expect(findAngleBracketPlaceholders('```ini\nKEY=<value>\n````')).toEqual(
[],
);
});
});
+266
View File
@@ -0,0 +1,266 @@
import fs from 'fs';
import path from 'path';
// Crowdin parses `<foo>` 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();
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
root: 'packages/twenty-docs',
include: ['scripts/**/*.spec.ts'],
globals: true,
},
});
+2
View File
@@ -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