@tuwaio/docs-ui 0.5.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -3
- package/bin/i18n-check.js +19 -0
- package/dist/components/DocsLayout.cjs +105 -0
- package/dist/components/DocsLayout.d.cts +93 -0
- package/dist/components/DocsLayout.d.ts +93 -0
- package/dist/components/DocsLayout.js +80 -0
- package/dist/components/Footer.cjs +1 -6
- package/dist/components/Footer.d.cts +3 -2
- package/dist/components/Footer.d.ts +3 -2
- package/dist/components/Footer.js +1 -6
- package/dist/components/LocalizedPage.cjs +42 -0
- package/dist/components/LocalizedPage.d.cts +37 -0
- package/dist/components/LocalizedPage.d.ts +37 -0
- package/dist/components/LocalizedPage.js +18 -0
- package/dist/docsI18n.cjs +33 -0
- package/dist/docsI18n.d.cts +16 -0
- package/dist/docsI18n.d.ts +16 -0
- package/dist/docsI18n.js +8 -0
- package/dist/docsLabels.cjs +140 -0
- package/dist/docsLabels.d.cts +72 -0
- package/dist/docsLabels.d.ts +72 -0
- package/dist/docsLabels.js +116 -0
- package/dist/docsPage.cjs +70 -0
- package/dist/docsPage.d.cts +56 -0
- package/dist/docsPage.d.ts +56 -0
- package/dist/docsPage.js +46 -0
- package/dist/i18n.d.cts +1 -1
- package/dist/i18n.d.ts +1 -1
- package/dist/index.cjs +4 -0
- package/dist/index.css +3 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/nextra.cjs +26 -0
- package/dist/nextra.d.cts +11 -0
- package/dist/nextra.d.ts +11 -0
- package/dist/nextra.js +3 -0
- package/dist/pageMap.cjs +66 -0
- package/dist/pageMap.d.cts +33 -0
- package/dist/pageMap.d.ts +33 -0
- package/dist/pageMap.js +42 -0
- package/dist/translationCheck.cjs +191 -0
- package/dist/translationCheck.d.cts +90 -0
- package/dist/translationCheck.d.ts +90 -0
- package/dist/translationCheck.js +162 -0
- package/package.json +15 -3
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var translationCheck_exports = {};
|
|
20
|
+
__export(translationCheck_exports, {
|
|
21
|
+
checkSiteTranslations: () => checkSiteTranslations,
|
|
22
|
+
checkTranslation: () => checkTranslation,
|
|
23
|
+
extractCodeBlocks: () => extractCodeBlocks,
|
|
24
|
+
extractHeadings: () => extractHeadings,
|
|
25
|
+
extractLinks: () => extractLinks,
|
|
26
|
+
loadNextraSlugger: () => loadNextraSlugger
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(translationCheck_exports);
|
|
29
|
+
var import_node_fs = require("node:fs");
|
|
30
|
+
var import_node_module = require("node:module");
|
|
31
|
+
var import_node_path = require("node:path");
|
|
32
|
+
var import_node_url = require("node:url");
|
|
33
|
+
var import_locale = require("./locale.js");
|
|
34
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
35
|
+
function splitFences(source) {
|
|
36
|
+
const blocks = [];
|
|
37
|
+
const prose = [];
|
|
38
|
+
let block = null;
|
|
39
|
+
let fence = "";
|
|
40
|
+
for (const line of source.split("\n")) {
|
|
41
|
+
if (block) {
|
|
42
|
+
block.push(line);
|
|
43
|
+
if (line.trim().startsWith(fence) && line.trim().replace(/[`~]/g, "") === "") {
|
|
44
|
+
blocks.push(block.join("\n"));
|
|
45
|
+
block = null;
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const open = FENCE_RE.exec(line);
|
|
50
|
+
if (open) {
|
|
51
|
+
fence = open[1];
|
|
52
|
+
block = [line];
|
|
53
|
+
} else {
|
|
54
|
+
prose.push(line);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (block) blocks.push(block.join("\n"));
|
|
58
|
+
return { blocks, prose: prose.join("\n") };
|
|
59
|
+
}
|
|
60
|
+
function extractCodeBlocks(source) {
|
|
61
|
+
return splitFences(source).blocks;
|
|
62
|
+
}
|
|
63
|
+
function plainText(markdown) {
|
|
64
|
+
return markdown.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/(\*\*|__)(.+?)\1/g, "$2").replace(/(\*|_)(.+?)\1/g, "$2").replace(/`([^`]*)`/g, "$1").trim();
|
|
65
|
+
}
|
|
66
|
+
function extractHeadings(source) {
|
|
67
|
+
const headings = [];
|
|
68
|
+
for (const line of splitFences(source).prose.split("\n")) {
|
|
69
|
+
const match = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
70
|
+
if (!match) continue;
|
|
71
|
+
const custom = /\s*\[#([^\]]+)\]\s*$/.exec(match[2]);
|
|
72
|
+
const text = plainText(custom ? match[2].slice(0, custom.index) : match[2]);
|
|
73
|
+
headings.push({ depth: match[1].length, text, ...custom && { customId: custom[1] } });
|
|
74
|
+
}
|
|
75
|
+
return headings;
|
|
76
|
+
}
|
|
77
|
+
function extractLinks(source) {
|
|
78
|
+
const prose = splitFences(source).prose.replace(/`[^`\n]*`/g, "");
|
|
79
|
+
const targets = [];
|
|
80
|
+
for (const match of prose.matchAll(/\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|\b(?:href|src)=["']([^"']+)["']/g)) {
|
|
81
|
+
targets.push(match[1] ?? match[2]);
|
|
82
|
+
}
|
|
83
|
+
return targets;
|
|
84
|
+
}
|
|
85
|
+
function headingIds(headings, createSlugger) {
|
|
86
|
+
const slug = createSlugger();
|
|
87
|
+
return headings.filter((heading) => heading.depth > 1).map((heading) => slug(heading.customId ?? heading.text));
|
|
88
|
+
}
|
|
89
|
+
const sorted = (values) => [...values].sort();
|
|
90
|
+
function checkTranslation(english, translated, createSlugger) {
|
|
91
|
+
const issues = [];
|
|
92
|
+
const englishBlocks = extractCodeBlocks(english);
|
|
93
|
+
const translatedBlocks = extractCodeBlocks(translated);
|
|
94
|
+
if (englishBlocks.length !== translatedBlocks.length) {
|
|
95
|
+
issues.push(`has ${translatedBlocks.length} code blocks, the English page ${englishBlocks.length}`);
|
|
96
|
+
} else {
|
|
97
|
+
englishBlocks.forEach((block, index) => {
|
|
98
|
+
if (block !== translatedBlocks[index]) issues.push(`code block ${index + 1} differs from the English one`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const englishHeadings = extractHeadings(english);
|
|
102
|
+
const translatedHeadings = extractHeadings(translated);
|
|
103
|
+
const levels = (headings) => headings.map((heading) => "#".repeat(heading.depth)).join(" ");
|
|
104
|
+
if (levels(englishHeadings) !== levels(translatedHeadings)) {
|
|
105
|
+
issues.push(`heading levels "${levels(translatedHeadings)}" differ from the English "${levels(englishHeadings)}"`);
|
|
106
|
+
} else if (createSlugger) {
|
|
107
|
+
const englishIds = headingIds(englishHeadings, createSlugger);
|
|
108
|
+
const translatedIds = headingIds(translatedHeadings, createSlugger);
|
|
109
|
+
englishIds.forEach((id, index) => {
|
|
110
|
+
if (id !== translatedIds[index]) {
|
|
111
|
+
issues.push(
|
|
112
|
+
`heading "${translatedHeadings.filter((h) => h.depth > 1)[index].text}" has the id "${translatedIds[index]}", the English one "${id}" (add [#${id}])`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const englishLinks = sorted(extractLinks(english));
|
|
118
|
+
const translatedLinks = sorted(extractLinks(translated));
|
|
119
|
+
const missing = englishLinks.filter((link) => !translatedLinks.includes(link));
|
|
120
|
+
const extra = translatedLinks.filter((link) => !englishLinks.includes(link));
|
|
121
|
+
if (missing.length || extra.length || englishLinks.length !== translatedLinks.length) {
|
|
122
|
+
issues.push(
|
|
123
|
+
`links differ from the English page (missing: ${missing.join(", ") || "none"}; not in English: ${extra.join(", ") || "none"}; ${translatedLinks.length} vs ${englishLinks.length})`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return issues;
|
|
127
|
+
}
|
|
128
|
+
function pageFiles(dir) {
|
|
129
|
+
if (!(0, import_node_fs.existsSync)(dir)) return [];
|
|
130
|
+
return (0, import_node_fs.readdirSync)(dir, { recursive: true, encoding: "utf8" }).filter((file) => [".md", ".mdx"].includes((0, import_node_path.extname)(file))).map((file) => file.split(import_node_path.sep).join("/")).sort();
|
|
131
|
+
}
|
|
132
|
+
const withoutExtension = (file) => file.slice(0, -(0, import_node_path.extname)(file).length);
|
|
133
|
+
async function loadNextraSlugger(cwd) {
|
|
134
|
+
try {
|
|
135
|
+
const nextra = (0, import_node_module.createRequire)((0, import_node_path.join)(cwd, "package.json")).resolve("nextra/package.json");
|
|
136
|
+
const sluggerPath = (0, import_node_module.createRequire)(nextra).resolve("github-slugger");
|
|
137
|
+
const { default: GithubSlugger } = await import((0, import_node_url.pathToFileURL)(sluggerPath).href);
|
|
138
|
+
return () => {
|
|
139
|
+
const slugger = new GithubSlugger();
|
|
140
|
+
return (text) => slugger.slug(text);
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
return void 0;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function checkSiteTranslations(options = {}) {
|
|
147
|
+
const { cwd = process.cwd(), contentDir = "src/content", i18nDir = "src/i18n" } = options;
|
|
148
|
+
if (!(0, import_node_fs.existsSync)((0, import_node_path.join)(cwd, contentDir))) {
|
|
149
|
+
throw new Error(`No ${contentDir} in ${cwd}: run the check from the docs app of the site`);
|
|
150
|
+
}
|
|
151
|
+
const createSlugger = options.createSlugger ?? await loadNextraSlugger(cwd);
|
|
152
|
+
if (!createSlugger) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`Cannot load github-slugger through nextra from ${cwd}: the heading ids of the translations cannot be checked`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const locales = import_locale.TUWA_LOCALES.filter((locale) => locale !== import_locale.DEFAULT_LOCALE);
|
|
158
|
+
const filesByLocale = new Map(locales.map((locale) => [locale, pageFiles((0, import_node_path.join)(cwd, i18nDir, locale))]));
|
|
159
|
+
const pages = [...new Set([...filesByLocale.values()].flat().map(withoutExtension))].sort();
|
|
160
|
+
const issues = [];
|
|
161
|
+
let files = 0;
|
|
162
|
+
for (const page of pages) {
|
|
163
|
+
const englishFile = [".mdx", ".md"].map((ext) => (0, import_node_path.join)(cwd, contentDir, page + ext)).find(import_node_fs.existsSync);
|
|
164
|
+
for (const locale of locales) {
|
|
165
|
+
const translatedFile = filesByLocale.get(locale)?.find((file) => withoutExtension(file) === page);
|
|
166
|
+
const shown = (0, import_node_path.join)(i18nDir, locale, translatedFile ?? `${page}.mdx`).split(import_node_path.sep).join("/");
|
|
167
|
+
if (!translatedFile) {
|
|
168
|
+
issues.push({ file: shown, message: "is missing: every translated page exists in every language" });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
files += 1;
|
|
172
|
+
if (!englishFile) {
|
|
173
|
+
issues.push({ file: shown, message: `has no English page ${(0, import_node_path.join)(contentDir, page)}.mdx` });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const english = (0, import_node_fs.readFileSync)(englishFile, "utf8");
|
|
177
|
+
const translated = (0, import_node_fs.readFileSync)((0, import_node_path.join)(cwd, i18nDir, locale, translatedFile), "utf8");
|
|
178
|
+
for (const message of checkTranslation(english, translated, createSlugger)) issues.push({ file: shown, message });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { files, issues };
|
|
182
|
+
}
|
|
183
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
184
|
+
0 && (module.exports = {
|
|
185
|
+
checkSiteTranslations,
|
|
186
|
+
checkTranslation,
|
|
187
|
+
extractCodeBlocks,
|
|
188
|
+
extractHeadings,
|
|
189
|
+
extractLinks,
|
|
190
|
+
loadNextraSlugger
|
|
191
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** A heading of a Markdown or MDX page (levels 1 to 6), outside code blocks. */
|
|
2
|
+
interface MarkdownHeading {
|
|
3
|
+
/** The level: 1 for `#`, 2 for `##`. */
|
|
4
|
+
depth: number;
|
|
5
|
+
/** The text without Markdown syntax and without the custom id. */
|
|
6
|
+
text: string;
|
|
7
|
+
/** The id written as `[#id]` at the end of the heading, as Nextra reads it. */
|
|
8
|
+
customId?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Turns a heading text into an id the way Nextra does (`github-slugger`, one instance per page). */
|
|
11
|
+
type HeadingSlugger = () => (text: string) => string;
|
|
12
|
+
/** A problem of a translated page. */
|
|
13
|
+
interface TranslationIssue {
|
|
14
|
+
/** The translated file, relative to the docs app. */
|
|
15
|
+
file: string;
|
|
16
|
+
/** What does not match the English page. */
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The fenced code blocks of a Markdown or MDX page, in order, each with its opening line (language and meta) and its
|
|
21
|
+
* closing fence.
|
|
22
|
+
*
|
|
23
|
+
* @param source - The page source.
|
|
24
|
+
* @returns The code blocks.
|
|
25
|
+
*/
|
|
26
|
+
declare function extractCodeBlocks(source: string): string[];
|
|
27
|
+
/**
|
|
28
|
+
* The headings of a Markdown or MDX page, outside code blocks.
|
|
29
|
+
*
|
|
30
|
+
* @param source - The page source.
|
|
31
|
+
* @returns The headings, in order.
|
|
32
|
+
*/
|
|
33
|
+
declare function extractHeadings(source: string): MarkdownHeading[];
|
|
34
|
+
/**
|
|
35
|
+
* The link and image targets of a Markdown or MDX page outside code blocks: `[text](url)`, `href="url"` and
|
|
36
|
+
* `src="url"`.
|
|
37
|
+
*
|
|
38
|
+
* @param source - The page source.
|
|
39
|
+
* @returns The targets, in order.
|
|
40
|
+
*/
|
|
41
|
+
declare function extractLinks(source: string): string[];
|
|
42
|
+
/**
|
|
43
|
+
* Compares a translated page with its English page. A translation keeps the structure of the English page so readers
|
|
44
|
+
* of every language get the same page: the same code blocks in the same order (examples are never translated), the
|
|
45
|
+
* same heading levels with the English heading ids (written as `[#id]`, so anchors work in every language) and the
|
|
46
|
+
* same links.
|
|
47
|
+
*
|
|
48
|
+
* @param english - The source of the English page.
|
|
49
|
+
* @param translated - The source of the translation.
|
|
50
|
+
* @param createSlugger - Creates the heading id function of a page (`() => { const s = new GithubSlugger(); return (text) => s.slug(text); }`);
|
|
51
|
+
* without it the ids are not compared.
|
|
52
|
+
* @returns What does not match, empty when the translation keeps the structure.
|
|
53
|
+
*/
|
|
54
|
+
declare function checkTranslation(english: string, translated: string, createSlugger?: HeadingSlugger): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Loads the heading slugger of Nextra (`github-slugger`, a dependency of `nextra`) from the site, so the check gives
|
|
57
|
+
* headings the ids the site gives them.
|
|
58
|
+
*
|
|
59
|
+
* @param cwd - The docs app, whose `node_modules` has `nextra`.
|
|
60
|
+
* @returns The slugger factory, or `undefined` when `nextra` or `github-slugger` cannot be found.
|
|
61
|
+
*/
|
|
62
|
+
declare function loadNextraSlugger(cwd: string): Promise<HeadingSlugger | undefined>;
|
|
63
|
+
/** Options of {@link checkSiteTranslations}. */
|
|
64
|
+
interface CheckSiteTranslationsOptions {
|
|
65
|
+
/** The docs app (default: the working directory). */
|
|
66
|
+
cwd?: string;
|
|
67
|
+
/** The English pages, relative to `cwd` (default: `src/content`). */
|
|
68
|
+
contentDir?: string;
|
|
69
|
+
/** The translations, one folder per language, relative to `cwd` (default: `src/i18n`). */
|
|
70
|
+
i18nDir?: string;
|
|
71
|
+
/** The heading slugger (default: {@link loadNextraSlugger}; the check fails when it cannot be loaded). */
|
|
72
|
+
createSlugger?: HeadingSlugger;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Checks the translated pages of a documentation site: `<i18nDir>/<locale>/<path>.mdx` translates
|
|
76
|
+
* `<contentDir>/<path>.mdx` (or the generated `.md`). Every translated page must exist in every language and keep the
|
|
77
|
+
* structure of its English page ({@link checkTranslation}). Reads files only.
|
|
78
|
+
*
|
|
79
|
+
* @param options - See {@link CheckSiteTranslationsOptions}.
|
|
80
|
+
* @returns The number of translated files and the issues found.
|
|
81
|
+
* @throws When `contentDir` does not exist (the check runs from the wrong directory), or when no `createSlugger` is
|
|
82
|
+
* given and the slugger of Nextra cannot be loaded from `cwd` (the heading ids would go unchecked).
|
|
83
|
+
*/
|
|
84
|
+
declare function checkSiteTranslations(options?: CheckSiteTranslationsOptions): Promise<{
|
|
85
|
+
files: number;
|
|
86
|
+
issues: TranslationIssue[];
|
|
87
|
+
}>;
|
|
88
|
+
|
|
89
|
+
export { checkSiteTranslations, checkTranslation, extractCodeBlocks, extractHeadings, extractLinks, loadNextraSlugger };
|
|
90
|
+
export type { CheckSiteTranslationsOptions, HeadingSlugger, MarkdownHeading, TranslationIssue };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** A heading of a Markdown or MDX page (levels 1 to 6), outside code blocks. */
|
|
2
|
+
interface MarkdownHeading {
|
|
3
|
+
/** The level: 1 for `#`, 2 for `##`. */
|
|
4
|
+
depth: number;
|
|
5
|
+
/** The text without Markdown syntax and without the custom id. */
|
|
6
|
+
text: string;
|
|
7
|
+
/** The id written as `[#id]` at the end of the heading, as Nextra reads it. */
|
|
8
|
+
customId?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Turns a heading text into an id the way Nextra does (`github-slugger`, one instance per page). */
|
|
11
|
+
type HeadingSlugger = () => (text: string) => string;
|
|
12
|
+
/** A problem of a translated page. */
|
|
13
|
+
interface TranslationIssue {
|
|
14
|
+
/** The translated file, relative to the docs app. */
|
|
15
|
+
file: string;
|
|
16
|
+
/** What does not match the English page. */
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The fenced code blocks of a Markdown or MDX page, in order, each with its opening line (language and meta) and its
|
|
21
|
+
* closing fence.
|
|
22
|
+
*
|
|
23
|
+
* @param source - The page source.
|
|
24
|
+
* @returns The code blocks.
|
|
25
|
+
*/
|
|
26
|
+
declare function extractCodeBlocks(source: string): string[];
|
|
27
|
+
/**
|
|
28
|
+
* The headings of a Markdown or MDX page, outside code blocks.
|
|
29
|
+
*
|
|
30
|
+
* @param source - The page source.
|
|
31
|
+
* @returns The headings, in order.
|
|
32
|
+
*/
|
|
33
|
+
declare function extractHeadings(source: string): MarkdownHeading[];
|
|
34
|
+
/**
|
|
35
|
+
* The link and image targets of a Markdown or MDX page outside code blocks: `[text](url)`, `href="url"` and
|
|
36
|
+
* `src="url"`.
|
|
37
|
+
*
|
|
38
|
+
* @param source - The page source.
|
|
39
|
+
* @returns The targets, in order.
|
|
40
|
+
*/
|
|
41
|
+
declare function extractLinks(source: string): string[];
|
|
42
|
+
/**
|
|
43
|
+
* Compares a translated page with its English page. A translation keeps the structure of the English page so readers
|
|
44
|
+
* of every language get the same page: the same code blocks in the same order (examples are never translated), the
|
|
45
|
+
* same heading levels with the English heading ids (written as `[#id]`, so anchors work in every language) and the
|
|
46
|
+
* same links.
|
|
47
|
+
*
|
|
48
|
+
* @param english - The source of the English page.
|
|
49
|
+
* @param translated - The source of the translation.
|
|
50
|
+
* @param createSlugger - Creates the heading id function of a page (`() => { const s = new GithubSlugger(); return (text) => s.slug(text); }`);
|
|
51
|
+
* without it the ids are not compared.
|
|
52
|
+
* @returns What does not match, empty when the translation keeps the structure.
|
|
53
|
+
*/
|
|
54
|
+
declare function checkTranslation(english: string, translated: string, createSlugger?: HeadingSlugger): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Loads the heading slugger of Nextra (`github-slugger`, a dependency of `nextra`) from the site, so the check gives
|
|
57
|
+
* headings the ids the site gives them.
|
|
58
|
+
*
|
|
59
|
+
* @param cwd - The docs app, whose `node_modules` has `nextra`.
|
|
60
|
+
* @returns The slugger factory, or `undefined` when `nextra` or `github-slugger` cannot be found.
|
|
61
|
+
*/
|
|
62
|
+
declare function loadNextraSlugger(cwd: string): Promise<HeadingSlugger | undefined>;
|
|
63
|
+
/** Options of {@link checkSiteTranslations}. */
|
|
64
|
+
interface CheckSiteTranslationsOptions {
|
|
65
|
+
/** The docs app (default: the working directory). */
|
|
66
|
+
cwd?: string;
|
|
67
|
+
/** The English pages, relative to `cwd` (default: `src/content`). */
|
|
68
|
+
contentDir?: string;
|
|
69
|
+
/** The translations, one folder per language, relative to `cwd` (default: `src/i18n`). */
|
|
70
|
+
i18nDir?: string;
|
|
71
|
+
/** The heading slugger (default: {@link loadNextraSlugger}; the check fails when it cannot be loaded). */
|
|
72
|
+
createSlugger?: HeadingSlugger;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Checks the translated pages of a documentation site: `<i18nDir>/<locale>/<path>.mdx` translates
|
|
76
|
+
* `<contentDir>/<path>.mdx` (or the generated `.md`). Every translated page must exist in every language and keep the
|
|
77
|
+
* structure of its English page ({@link checkTranslation}). Reads files only.
|
|
78
|
+
*
|
|
79
|
+
* @param options - See {@link CheckSiteTranslationsOptions}.
|
|
80
|
+
* @returns The number of translated files and the issues found.
|
|
81
|
+
* @throws When `contentDir` does not exist (the check runs from the wrong directory), or when no `createSlugger` is
|
|
82
|
+
* given and the slugger of Nextra cannot be loaded from `cwd` (the heading ids would go unchecked).
|
|
83
|
+
*/
|
|
84
|
+
declare function checkSiteTranslations(options?: CheckSiteTranslationsOptions): Promise<{
|
|
85
|
+
files: number;
|
|
86
|
+
issues: TranslationIssue[];
|
|
87
|
+
}>;
|
|
88
|
+
|
|
89
|
+
export { checkSiteTranslations, checkTranslation, extractCodeBlocks, extractHeadings, extractLinks, loadNextraSlugger };
|
|
90
|
+
export type { CheckSiteTranslationsOptions, HeadingSlugger, MarkdownHeading, TranslationIssue };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { extname, join, sep } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { DEFAULT_LOCALE, TUWA_LOCALES } from "./locale.js";
|
|
6
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
7
|
+
function splitFences(source) {
|
|
8
|
+
const blocks = [];
|
|
9
|
+
const prose = [];
|
|
10
|
+
let block = null;
|
|
11
|
+
let fence = "";
|
|
12
|
+
for (const line of source.split("\n")) {
|
|
13
|
+
if (block) {
|
|
14
|
+
block.push(line);
|
|
15
|
+
if (line.trim().startsWith(fence) && line.trim().replace(/[`~]/g, "") === "") {
|
|
16
|
+
blocks.push(block.join("\n"));
|
|
17
|
+
block = null;
|
|
18
|
+
}
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const open = FENCE_RE.exec(line);
|
|
22
|
+
if (open) {
|
|
23
|
+
fence = open[1];
|
|
24
|
+
block = [line];
|
|
25
|
+
} else {
|
|
26
|
+
prose.push(line);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (block) blocks.push(block.join("\n"));
|
|
30
|
+
return { blocks, prose: prose.join("\n") };
|
|
31
|
+
}
|
|
32
|
+
function extractCodeBlocks(source) {
|
|
33
|
+
return splitFences(source).blocks;
|
|
34
|
+
}
|
|
35
|
+
function plainText(markdown) {
|
|
36
|
+
return markdown.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/(\*\*|__)(.+?)\1/g, "$2").replace(/(\*|_)(.+?)\1/g, "$2").replace(/`([^`]*)`/g, "$1").trim();
|
|
37
|
+
}
|
|
38
|
+
function extractHeadings(source) {
|
|
39
|
+
const headings = [];
|
|
40
|
+
for (const line of splitFences(source).prose.split("\n")) {
|
|
41
|
+
const match = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
const custom = /\s*\[#([^\]]+)\]\s*$/.exec(match[2]);
|
|
44
|
+
const text = plainText(custom ? match[2].slice(0, custom.index) : match[2]);
|
|
45
|
+
headings.push({ depth: match[1].length, text, ...custom && { customId: custom[1] } });
|
|
46
|
+
}
|
|
47
|
+
return headings;
|
|
48
|
+
}
|
|
49
|
+
function extractLinks(source) {
|
|
50
|
+
const prose = splitFences(source).prose.replace(/`[^`\n]*`/g, "");
|
|
51
|
+
const targets = [];
|
|
52
|
+
for (const match of prose.matchAll(/\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|\b(?:href|src)=["']([^"']+)["']/g)) {
|
|
53
|
+
targets.push(match[1] ?? match[2]);
|
|
54
|
+
}
|
|
55
|
+
return targets;
|
|
56
|
+
}
|
|
57
|
+
function headingIds(headings, createSlugger) {
|
|
58
|
+
const slug = createSlugger();
|
|
59
|
+
return headings.filter((heading) => heading.depth > 1).map((heading) => slug(heading.customId ?? heading.text));
|
|
60
|
+
}
|
|
61
|
+
const sorted = (values) => [...values].sort();
|
|
62
|
+
function checkTranslation(english, translated, createSlugger) {
|
|
63
|
+
const issues = [];
|
|
64
|
+
const englishBlocks = extractCodeBlocks(english);
|
|
65
|
+
const translatedBlocks = extractCodeBlocks(translated);
|
|
66
|
+
if (englishBlocks.length !== translatedBlocks.length) {
|
|
67
|
+
issues.push(`has ${translatedBlocks.length} code blocks, the English page ${englishBlocks.length}`);
|
|
68
|
+
} else {
|
|
69
|
+
englishBlocks.forEach((block, index) => {
|
|
70
|
+
if (block !== translatedBlocks[index]) issues.push(`code block ${index + 1} differs from the English one`);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const englishHeadings = extractHeadings(english);
|
|
74
|
+
const translatedHeadings = extractHeadings(translated);
|
|
75
|
+
const levels = (headings) => headings.map((heading) => "#".repeat(heading.depth)).join(" ");
|
|
76
|
+
if (levels(englishHeadings) !== levels(translatedHeadings)) {
|
|
77
|
+
issues.push(`heading levels "${levels(translatedHeadings)}" differ from the English "${levels(englishHeadings)}"`);
|
|
78
|
+
} else if (createSlugger) {
|
|
79
|
+
const englishIds = headingIds(englishHeadings, createSlugger);
|
|
80
|
+
const translatedIds = headingIds(translatedHeadings, createSlugger);
|
|
81
|
+
englishIds.forEach((id, index) => {
|
|
82
|
+
if (id !== translatedIds[index]) {
|
|
83
|
+
issues.push(
|
|
84
|
+
`heading "${translatedHeadings.filter((h) => h.depth > 1)[index].text}" has the id "${translatedIds[index]}", the English one "${id}" (add [#${id}])`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const englishLinks = sorted(extractLinks(english));
|
|
90
|
+
const translatedLinks = sorted(extractLinks(translated));
|
|
91
|
+
const missing = englishLinks.filter((link) => !translatedLinks.includes(link));
|
|
92
|
+
const extra = translatedLinks.filter((link) => !englishLinks.includes(link));
|
|
93
|
+
if (missing.length || extra.length || englishLinks.length !== translatedLinks.length) {
|
|
94
|
+
issues.push(
|
|
95
|
+
`links differ from the English page (missing: ${missing.join(", ") || "none"}; not in English: ${extra.join(", ") || "none"}; ${translatedLinks.length} vs ${englishLinks.length})`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return issues;
|
|
99
|
+
}
|
|
100
|
+
function pageFiles(dir) {
|
|
101
|
+
if (!existsSync(dir)) return [];
|
|
102
|
+
return readdirSync(dir, { recursive: true, encoding: "utf8" }).filter((file) => [".md", ".mdx"].includes(extname(file))).map((file) => file.split(sep).join("/")).sort();
|
|
103
|
+
}
|
|
104
|
+
const withoutExtension = (file) => file.slice(0, -extname(file).length);
|
|
105
|
+
async function loadNextraSlugger(cwd) {
|
|
106
|
+
try {
|
|
107
|
+
const nextra = createRequire(join(cwd, "package.json")).resolve("nextra/package.json");
|
|
108
|
+
const sluggerPath = createRequire(nextra).resolve("github-slugger");
|
|
109
|
+
const { default: GithubSlugger } = await import(pathToFileURL(sluggerPath).href);
|
|
110
|
+
return () => {
|
|
111
|
+
const slugger = new GithubSlugger();
|
|
112
|
+
return (text) => slugger.slug(text);
|
|
113
|
+
};
|
|
114
|
+
} catch {
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function checkSiteTranslations(options = {}) {
|
|
119
|
+
const { cwd = process.cwd(), contentDir = "src/content", i18nDir = "src/i18n" } = options;
|
|
120
|
+
if (!existsSync(join(cwd, contentDir))) {
|
|
121
|
+
throw new Error(`No ${contentDir} in ${cwd}: run the check from the docs app of the site`);
|
|
122
|
+
}
|
|
123
|
+
const createSlugger = options.createSlugger ?? await loadNextraSlugger(cwd);
|
|
124
|
+
if (!createSlugger) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Cannot load github-slugger through nextra from ${cwd}: the heading ids of the translations cannot be checked`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const locales = TUWA_LOCALES.filter((locale) => locale !== DEFAULT_LOCALE);
|
|
130
|
+
const filesByLocale = new Map(locales.map((locale) => [locale, pageFiles(join(cwd, i18nDir, locale))]));
|
|
131
|
+
const pages = [...new Set([...filesByLocale.values()].flat().map(withoutExtension))].sort();
|
|
132
|
+
const issues = [];
|
|
133
|
+
let files = 0;
|
|
134
|
+
for (const page of pages) {
|
|
135
|
+
const englishFile = [".mdx", ".md"].map((ext) => join(cwd, contentDir, page + ext)).find(existsSync);
|
|
136
|
+
for (const locale of locales) {
|
|
137
|
+
const translatedFile = filesByLocale.get(locale)?.find((file) => withoutExtension(file) === page);
|
|
138
|
+
const shown = join(i18nDir, locale, translatedFile ?? `${page}.mdx`).split(sep).join("/");
|
|
139
|
+
if (!translatedFile) {
|
|
140
|
+
issues.push({ file: shown, message: "is missing: every translated page exists in every language" });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
files += 1;
|
|
144
|
+
if (!englishFile) {
|
|
145
|
+
issues.push({ file: shown, message: `has no English page ${join(contentDir, page)}.mdx` });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const english = readFileSync(englishFile, "utf8");
|
|
149
|
+
const translated = readFileSync(join(cwd, i18nDir, locale, translatedFile), "utf8");
|
|
150
|
+
for (const message of checkTranslation(english, translated, createSlugger)) issues.push({ file: shown, message });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { files, issues };
|
|
154
|
+
}
|
|
155
|
+
export {
|
|
156
|
+
checkSiteTranslations,
|
|
157
|
+
checkTranslation,
|
|
158
|
+
extractCodeBlocks,
|
|
159
|
+
extractHeadings,
|
|
160
|
+
extractLinks,
|
|
161
|
+
loadNextraSlugger
|
|
162
|
+
};
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuwaio/docs-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"author": "Oleksandr Tkach",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
|
-
"description": "Shared UI of the TUWA documentation sites: Nextra navbar and
|
|
7
|
+
"description": "Shared UI of the TUWA documentation sites: Nextra navbar, footer and layout in English, Ukrainian and Simplified Chinese, remote logo, theme and language switches, background effects, policy pages and styles.",
|
|
8
8
|
"main": "./dist/index.cjs",
|
|
9
9
|
"module": "./dist/index.js",
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"bin": {
|
|
12
|
-
"tuwa-docs-search-index": "./bin/search-index.js"
|
|
12
|
+
"tuwa-docs-search-index": "./bin/search-index.js",
|
|
13
|
+
"tuwa-docs-i18n-check": "./bin/i18n-check.js"
|
|
13
14
|
},
|
|
14
15
|
"type": "module",
|
|
15
16
|
"exports": {
|
|
@@ -33,6 +34,16 @@
|
|
|
33
34
|
"import": "./dist/i18n.js",
|
|
34
35
|
"require": "./dist/i18n.cjs"
|
|
35
36
|
},
|
|
37
|
+
"./nextra": {
|
|
38
|
+
"types": "./dist/nextra.d.ts",
|
|
39
|
+
"import": "./dist/nextra.js",
|
|
40
|
+
"require": "./dist/nextra.cjs"
|
|
41
|
+
},
|
|
42
|
+
"./nextra/page": {
|
|
43
|
+
"types": "./dist/docsPage.d.ts",
|
|
44
|
+
"import": "./dist/docsPage.js",
|
|
45
|
+
"require": "./dist/docsPage.cjs"
|
|
46
|
+
},
|
|
36
47
|
"./dist/index.css": "./dist/index.css",
|
|
37
48
|
"./dist/*": "./dist/*"
|
|
38
49
|
},
|
|
@@ -79,6 +90,7 @@
|
|
|
79
90
|
"peerDependencies": {
|
|
80
91
|
"@heroicons/react": ">=2",
|
|
81
92
|
"next": ">=16.1",
|
|
93
|
+
"nextra": ">=4.6",
|
|
82
94
|
"nextra-theme-docs": ">=4.6",
|
|
83
95
|
"react": ">=19.2.3"
|
|
84
96
|
},
|