mirror of
https://github.com/twentyhq/twenty.git
synced 2026-09-16 07:56:04 -04:00
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">``<img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</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:
7 files changed
+428
-2
No files matched your search
@@ -39,3 +39,5 @@ jobs:
|
||||
- name: Docs / Lint
|
||||
run: npx nx lint twenty-docs
|
||||
|
||||
- name: Docs / Test
|
||||
run: npx nx test twenty-docs
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root: 'packages/twenty-docs',
|
||||
include: ['scripts/**/*.spec.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user