Files
upstream-twenty/packages/twenty-docs/scripts/lint-mdx.ts
upstream-mirror e7da97194a
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

267 lines
5.7 KiB
TypeScript

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();
}