blob: 0823933cdb4f72fa9b4add835d18b510d1fe2bcd [file]
const matter = require('gray-matter');
const {hash} = require('./state.cjs');
const headingsPlugin = require('@docusaurus/mdx-loader/lib/remark/headings').default;
const {escapeMarkdownHeadingIds} = require('@docusaurus/utils');
const {getFormat} = require('@docusaurus/mdx-loader/lib/format.js');
const {normalizedNumbers} = require('./protection.cjs');
const {buildCache} = require('./build-cache.cjs');
function withoutPositions(value) {
if (Array.isArray(value)) return value.map(withoutPositions);
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value)
.filter(([key]) => !['position', 'start', 'end', 'loc', 'range'].includes(key))
.map(([key, child]) => [key, withoutPositions(child)]));
return value;
}
async function structure(raw, extension, anchorsMaintainCase = false) {
return buildCache.get('structure', [raw, extension, anchorsMaintainCase],
() => parseStructure(raw, extension, anchorsMaintainCase));
}
async function parseStructure(raw, extension, anchorsMaintainCase) {
if (/^\.tsx?$/.test(extension)) {
const {parseScript} = require('./code-prose.cjs');
const file = parseScript(raw);
return {content: raw, headings: [], format: 'code', technicalMetadata: {}, proseNumbers: [],
protectedNodes: [['script', null, null, raw]], technicalHash: hash(raw), semanticHash: hash(raw),
blocks: file.statements.map(node => ({kind: 'script', start: node.getFullStart(), end: node.end}))};
}
const [{unified}, {default: parse}, {default: gfm}, {default: mdx}, {visit}, {default: directive}] = await Promise.all([
import('unified'), import('remark-parse'), import('remark-gfm'), import('remark-mdx'), import('unist-util-visit'),
import('remark-directive'),
]);
// Use native format detection, including front-matter MDX overrides.
const {data, content} = matter(raw, {});
const format = getFormat({filePath: `doc${extension}`, frontMatterFormat: data.mdx?.format, markdownConfigFormat: 'detect'});
const processor = unified().use(parse).use(gfm);
if (format === 'mdx') processor.use(mdx);
const parseContent = format === 'mdx' ? escapeMarkdownHeadingIds(content) : content;
const offsets = [];
let originalOffset = 0;
for (let i = 0; i < parseContent.length; i++) {
offsets[i] = originalOffset;
if (parseContent[i] === content[originalOffset]) originalOffset++;
else if (parseContent[i] !== '\\') throw new Error('Unexpected heading preprocessing change');
}
offsets[parseContent.length] = content.length;
const tree = processor.parse(parseContent);
// Existing state records fingerprint this original parse. Keep its IDs and
// protected nodes stable so a rendering fix does not invalidate translations;
// directive-like prose such as `:s3` also keeps its protected number here.
const fingerprintHeadings = [];
await headingsPlugin({anchorsMaintainCase})(tree);
visit(tree, 'heading', node => {
fingerprintHeadings.push({depth: node.depth, id: node.data.id});
});
// Docusaurus parses directives before assigning heading IDs. In particular,
// `error:protocol` contains a textDirective whose name is absent from the ID.
// Parse the original content again to preserve its raw heading offsets, and
// use the installed framework's heading transform, including its duplicate
// slug order and explicit-ID behavior, instead of a second slug algorithm.
const headingTree = processor().use(directive).parse(parseContent);
await headingsPlugin({anchorsMaintainCase})(headingTree);
const headings = [];
visit(headingTree, 'heading', node => {
headings.push({depth: node.depth, id: node.data.id, position: {
start: {...node.position.start, offset: offsets[node.position.start.offset]},
end: {...node.position.end, offset: offsets[node.position.end.offset]},
}});
});
const protectedNodes = [];
const proseNumbers = [];
visit(tree, node => {
if (['code', 'inlineCode', 'html', 'mdxjsEsm', 'mdxFlowExpression', 'mdxTextExpression'].includes(node.type)) {
protectedNodes.push([node.type, node.lang ?? null, node.meta ?? null, node.value]);
}
if (['link', 'image', 'definition', 'linkReference', 'imageReference'].includes(node.type)) {
protectedNodes.push([node.type, node.url ?? null, node.identifier ?? null]);
}
if (node.type.startsWith('mdxJsx')) protectedNodes.push([node.type, node.name, withoutPositions(node.attributes)]);
if (node.type === 'text') {
proseNumbers.push(...normalizedNumbers(node.value));
const numbers = node.value.match(/\d+(?:\.\d+)*/g);
if (numbers) protectedNodes.push(['numbers', numbers]);
}
});
// Only title, description and sidebar_label are prose. Treat every other
// front-matter change conservatively, including explicit last_update.
const technicalMetadata = Object.fromEntries(Object.entries(data)
.filter(([key]) => !['title', 'description', 'sidebar_label'].includes(key))
.sort(([a], [b]) => a.localeCompare(b)));
return {
content, headings, format, protectedNodes, proseNumbers, technicalMetadata,
// Raw top-level ranges let incremental translations keep untouched
// target blocks, including their original Markdown formatting.
blocks: [
...(raw.length > content.length ? [{kind: 'frontmatter', start: 0, end: raw.length - content.length}] : []),
...tree.children.map(node => ({kind: node.type === 'heading' ? `heading:${node.depth}` : node.type,
start: raw.length - content.length + offsets[node.position.start.offset],
end: raw.length - content.length + offsets[node.position.end.offset]})),
],
technicalHash: hash(JSON.stringify([technicalMetadata, protectedNodes, fingerprintHeadings])),
// A secondary comparison for planning only. The state sourceHash stays
// byte-sensitive, so a formatting edit still needs an explicit review.
semanticHash: hash(JSON.stringify([Object.fromEntries(Object.entries(data).sort(([a], [b]) => a.localeCompare(b))),
withoutPositions(tree)])),
};
}
async function alignHeadings(sourceStructure, target, extension, maintainCase = false) {
const headings = sourceStructure.headings.map(({depth, id}) => [depth, id]);
return buildCache.get('aligned-headings', [headings, target, extension, maintainCase],
() => alignTargetHeadings(sourceStructure, target, extension, maintainCase));
}
async function alignTargetHeadings(sourceStructure, target, extension, maintainCase) {
const parsed = await structure(target, extension, maintainCase);
if (parsed.headings.length !== sourceStructure.headings.length
|| parsed.headings.some((heading, i) => heading.depth !== sourceStructure.headings[i].depth)) {
throw new Error('Translation heading structure differs from its current source');
}
let content = parsed.content;
for (let i = parsed.headings.length - 1; i >= 0; i--) {
const heading = parsed.headings[i];
const {start, end} = heading.position;
const original = content.slice(start.offset, end.offset);
const id = sourceStructure.headings[i].id;
// ATX and setext headings both receive the source ID in their text.
const lines = original.split('\n');
const textLine = /^\s{0,3}(?:=+|-+)\s*$/.test(lines[lines.length - 1]) ? lines.length - 2 : lines.length - 1;
const marker = parsed.format === 'mdx' && textLine !== lines.length - 1 ? ` \\{#${id}}` : ` {#${id}}`;
lines[textLine] = lines[textLine].replace(/\s*\\?\{#[^}]+\}\s*(?:#+\s*)?$/, '').replace(/\s+#+\s*$/, '') + marker;
content = content.slice(0, start.offset) + lines.join('\n') + content.slice(end.offset);
}
// Keep original front matter bytes (and raw targetHash). Only the ignored
// view is changed; committed Japanese documents are never rewritten.
const prefix = target.slice(0, target.length - parsed.content.length);
return prefix + content;
}
module.exports = {structure, alignHeadings};