gt 2.17.2 → 2.17.3
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/CHANGELOG.md +43 -0
- package/dist/cli/commands/translate.js +1 -1
- package/dist/cli/commands/translate.js.map +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/generated/version.js.map +1 -1
- package/dist/utils/addExplicitAnchorIds.d.ts +21 -1
- package/dist/utils/addExplicitAnchorIds.js +130 -198
- package/dist/utils/addExplicitAnchorIds.js.map +1 -1
- package/dist/utils/localizeRelativeAssets.js +6 -3
- package/dist/utils/localizeRelativeAssets.js.map +1 -1
- package/dist/utils/localizeStaticImports.js +2 -7
- package/dist/utils/localizeStaticImports.js.map +1 -1
- package/dist/utils/localizeStaticUrls.js +6 -4
- package/dist/utils/localizeStaticUrls.js.map +1 -1
- package/dist/utils/mdxAnchorSyntax.d.ts +30 -0
- package/dist/utils/mdxAnchorSyntax.js +113 -0
- package/dist/utils/mdxAnchorSyntax.js.map +1 -0
- package/dist/utils/validateMdx.d.ts +5 -0
- package/dist/utils/validateMdx.js +7 -7
- package/dist/utils/validateMdx.js.map +1 -1
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# gtx-cli
|
|
2
2
|
|
|
3
|
+
## 2.17.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#2221](https://github.com/generaltranslation/gt/pull/2221) [`cadd04a`](https://github.com/generaltranslation/gt/commit/cadd04a07d73fd6090c3f72a199f03e443b5db10) Thanks [@fernando-aviles](https://github.com/fernando-aviles)! - Fix custom heading IDs (`## Heading {#id}`) being dropped or misapplied in translated MD/MDX.
|
|
8
|
+
|
|
9
|
+
`{#id}` is not valid MDX — remark-mdx hands it to acorn as an expression — so files
|
|
10
|
+
using Mintlify's custom heading ID syntax failed validation and were skipped
|
|
11
|
+
entirely. Parsing now tolerates the syntax, so `skipFileValidation` is no longer
|
|
12
|
+
needed to translate those files.
|
|
13
|
+
|
|
14
|
+
Anchor IDs are also applied far more reliably:
|
|
15
|
+
- Headings are located by parser line positions instead of by matching heading
|
|
16
|
+
text, so indentation (headings nested in `<Tabs>`, `<Steps>`, `<Accordion>`),
|
|
17
|
+
inline JSX, escaped characters and repeated heading text no longer cause a
|
|
18
|
+
heading to be skipped or an ID to land on the wrong heading.
|
|
19
|
+
- Repeated headings now get unique IDs (`slug`, `slug-2`, `slug-3`) matching how
|
|
20
|
+
Mintlify disambiguates them, instead of emitting the same ID several times.
|
|
21
|
+
- Source and translated files are now read with the same extractor. Previously a
|
|
22
|
+
source using `{#id}` fell back to line scanning while its translation used the
|
|
23
|
+
AST, so the two heading lists could disagree and shift every ID after the first
|
|
24
|
+
nested heading.
|
|
25
|
+
- In `experimentalAddHeaderAnchorIds: 'mintlify'` mode, an author-written `{#id}`
|
|
26
|
+
is carried into the translation in Mintlify's native inline syntax rather than
|
|
27
|
+
being replaced by a `<div id>` wrapper. Wrappers are still used for IDs the CLI
|
|
28
|
+
derives from heading text.
|
|
29
|
+
- Applying an anchor no longer re-stringifies the whole document, so it no longer
|
|
30
|
+
HTML-escapes unrelated heading text or reformats the file.
|
|
31
|
+
- An existing wrapper is recognized from the parsed tree rather than by matching
|
|
32
|
+
the tag's text, so extra attributes, single quotes, a multi-line tag or a
|
|
33
|
+
non-`div` element no longer cause a second wrapper to be nested inside the first.
|
|
34
|
+
- An inline anchor is inserted before a heading's closing `##` sequence instead of
|
|
35
|
+
after it, which previously turned the closing hashes into visible heading text.
|
|
36
|
+
|
|
37
|
+
Anchor processing now runs after the other MD/MDX post-processing passes, which
|
|
38
|
+
re-indent headings nested in JSX when they stringify.
|
|
39
|
+
|
|
40
|
+
- Updated dependencies []:
|
|
41
|
+
- generaltranslation@9.1.10
|
|
42
|
+
- @generaltranslation/python-extractor@0.2.43
|
|
43
|
+
- @generaltranslation/supported-locales@2.1.23
|
|
44
|
+
- @generaltranslation/vue-extractor@0.1.3
|
|
45
|
+
|
|
3
46
|
## 2.17.2
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
|
@@ -49,8 +49,8 @@ async function postProcessTranslations(settings, includeFiles) {
|
|
|
49
49
|
const nonDefaultLocales = settings.locales.filter((locale) => locale !== settings.defaultLocale);
|
|
50
50
|
if (nonDefaultLocales.length > 0) await localizeRelativeAssets(settings, nonDefaultLocales, postProcessIncludes);
|
|
51
51
|
}
|
|
52
|
-
if (settings.options?.experimentalLocalizeStaticUrls || settings.options?.experimentalAddHeaderAnchorIds) await processAnchorIds(settings, postProcessIncludes);
|
|
53
52
|
if (settings.options?.experimentalLocalizeStaticImports) await localizeStaticImports(settings, postProcessIncludes);
|
|
53
|
+
if (settings.options?.experimentalLocalizeStaticUrls || settings.options?.experimentalAddHeaderAnchorIds) await processAnchorIds(settings, postProcessIncludes);
|
|
54
54
|
if (settings.options?.experimentalFlattenJsonFiles) await flattenJsonFiles(settings, postProcessIncludes);
|
|
55
55
|
if (settings.options?.copyFiles) await copyFile(settings);
|
|
56
56
|
persistPostProcessHashes(settings, postProcessIncludes, getDownloadedMeta());
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"translate.js","names":[],"sources":["../../../src/cli/commands/translate.ts"],"sourcesContent":["import { EnqueueFilesResult } from 'generaltranslation/types';\nimport { TranslateFlags } from '../../types/index.js';\nimport { Settings } from '../../types/index.js';\nimport {\n FileTranslationData,\n runDownloadWorkflow,\n} from '../../workflows/download.js';\nimport { createFileMapping } from '../../formats/files/fileMapping.js';\nimport copyFile from '../../fs/copyFile.js';\nimport flattenJsonFiles from '../../utils/flattenJsonFiles.js';\nimport localizeStaticUrls from '../../utils/localizeStaticUrls.js';\nimport localizeRelativeAssets from '../../utils/localizeRelativeAssets.js';\nimport processAnchorIds from '../../utils/processAnchorIds.js';\nimport localizeStaticImports from '../../utils/localizeStaticImports.js';\nimport { postprocessMintlify } from '../../formats/files/postprocess/mintlify.js';\nimport { BranchData } from '../../types/branch.js';\nimport { getDownloadedMeta } from '../../state/recentDownloads.js';\nimport { persistPostProcessHashes } from '../../utils/persistPostprocessHashes.js';\nimport { runPublishWorkflow } from '../../workflows/publish.js';\nimport { SUPPORTED_FILE_EXTENSIONS } from '../../formats/files/supportedFiles.js';\nimport { hasNonIdentityFileFormatTransformForType } from '../../formats/files/transformFormat.js';\nimport { getRelative } from '../../fs/findFilepath.js';\nimport type { InlineLibrary } from '../../types/libraries.js';\n\n// Downloads translations that were completed\nexport async function handleTranslate(\n options: TranslateFlags,\n settings: Settings,\n fileVersionData: FileTranslationData | undefined,\n jobData: EnqueueFilesResult | undefined,\n branchData: BranchData | undefined,\n publishMap?: Map<string, boolean>,\n inlineLibrary?: InlineLibrary\n) {\n if (fileVersionData) {\n const {\n resolvedPaths,\n placeholderPaths,\n transformPaths,\n transformFormats,\n } = settings.files;\n\n const fileMapping = createFileMapping(\n resolvedPaths,\n placeholderPaths,\n transformPaths,\n transformFormats,\n settings.locales,\n settings.defaultLocale\n );\n // Check for remaining translations\n await runDownloadWorkflow({\n fileVersionData: fileVersionData,\n jobData: jobData,\n branchData: branchData,\n locales: settings.locales,\n timeoutDuration: options.timeout,\n resolveOutputPath: (sourcePath, locale) =>\n fileMapping[locale]?.[sourcePath] ?? null,\n options: settings,\n inlineLibrary,\n forceRetranslation: options.force,\n forceDownload: options.forceDownload || options.force, // if force is true should also force download\n });\n\n // Publish/unpublish files after translations are downloaded\n if (publishMap && branchData?.currentBranch.id) {\n const files = Object.entries(fileVersionData).map(([fileId, data]) => ({\n fileId,\n versionId: data.versionId,\n fileName: data.fileName,\n }));\n await runPublishWorkflow(\n files,\n publishMap,\n branchData.currentBranch.id,\n settings\n );\n }\n }\n}\n\nexport async function postProcessTranslations(\n settings: Settings,\n includeFiles?: Set<string>\n) {\n const postProcessIncludes = filterPostProcessIncludesForFormatTransforms(\n settings,\n includeFiles\n );\n if (includeFiles && postProcessIncludes?.size === 0) return;\n\n await postprocessMintlify(settings, postProcessIncludes);\n\n // Localize static urls (/docs -> /[locale]/docs) and preserve anchor IDs for non-default locales\n // Default locale is processed earlier in the flow in base.ts\n if (settings.options?.experimentalLocalizeStaticUrls) {\n const nonDefaultLocales = settings.locales.filter(\n (locale) => locale !== settings.defaultLocale\n );\n if (nonDefaultLocales.length > 0) {\n await localizeStaticUrls(\n settings,\n nonDefaultLocales,\n postProcessIncludes\n );\n }\n }\n\n // Rewrite relative asset URLs in translated md/mdx files\n if (settings.options?.experimentalLocalizeRelativeAssets) {\n const nonDefaultLocales = settings.locales.filter(\n (locale) => locale !== settings.defaultLocale\n );\n if (nonDefaultLocales.length > 0) {\n await localizeRelativeAssets(\n settings,\n nonDefaultLocales,\n postProcessIncludes\n );\n }\n }\n\n const shouldProcessAnchorIds =\n settings.options?.experimentalLocalizeStaticUrls ||\n settings.options?.experimentalAddHeaderAnchorIds;\n\n // Add explicit anchor IDs to translated MDX/MD files to preserve navigation
|
|
1
|
+
{"version":3,"file":"translate.js","names":[],"sources":["../../../src/cli/commands/translate.ts"],"sourcesContent":["import { EnqueueFilesResult } from 'generaltranslation/types';\nimport { TranslateFlags } from '../../types/index.js';\nimport { Settings } from '../../types/index.js';\nimport {\n FileTranslationData,\n runDownloadWorkflow,\n} from '../../workflows/download.js';\nimport { createFileMapping } from '../../formats/files/fileMapping.js';\nimport copyFile from '../../fs/copyFile.js';\nimport flattenJsonFiles from '../../utils/flattenJsonFiles.js';\nimport localizeStaticUrls from '../../utils/localizeStaticUrls.js';\nimport localizeRelativeAssets from '../../utils/localizeRelativeAssets.js';\nimport processAnchorIds from '../../utils/processAnchorIds.js';\nimport localizeStaticImports from '../../utils/localizeStaticImports.js';\nimport { postprocessMintlify } from '../../formats/files/postprocess/mintlify.js';\nimport { BranchData } from '../../types/branch.js';\nimport { getDownloadedMeta } from '../../state/recentDownloads.js';\nimport { persistPostProcessHashes } from '../../utils/persistPostprocessHashes.js';\nimport { runPublishWorkflow } from '../../workflows/publish.js';\nimport { SUPPORTED_FILE_EXTENSIONS } from '../../formats/files/supportedFiles.js';\nimport { hasNonIdentityFileFormatTransformForType } from '../../formats/files/transformFormat.js';\nimport { getRelative } from '../../fs/findFilepath.js';\nimport type { InlineLibrary } from '../../types/libraries.js';\n\n// Downloads translations that were completed\nexport async function handleTranslate(\n options: TranslateFlags,\n settings: Settings,\n fileVersionData: FileTranslationData | undefined,\n jobData: EnqueueFilesResult | undefined,\n branchData: BranchData | undefined,\n publishMap?: Map<string, boolean>,\n inlineLibrary?: InlineLibrary\n) {\n if (fileVersionData) {\n const {\n resolvedPaths,\n placeholderPaths,\n transformPaths,\n transformFormats,\n } = settings.files;\n\n const fileMapping = createFileMapping(\n resolvedPaths,\n placeholderPaths,\n transformPaths,\n transformFormats,\n settings.locales,\n settings.defaultLocale\n );\n // Check for remaining translations\n await runDownloadWorkflow({\n fileVersionData: fileVersionData,\n jobData: jobData,\n branchData: branchData,\n locales: settings.locales,\n timeoutDuration: options.timeout,\n resolveOutputPath: (sourcePath, locale) =>\n fileMapping[locale]?.[sourcePath] ?? null,\n options: settings,\n inlineLibrary,\n forceRetranslation: options.force,\n forceDownload: options.forceDownload || options.force, // if force is true should also force download\n });\n\n // Publish/unpublish files after translations are downloaded\n if (publishMap && branchData?.currentBranch.id) {\n const files = Object.entries(fileVersionData).map(([fileId, data]) => ({\n fileId,\n versionId: data.versionId,\n fileName: data.fileName,\n }));\n await runPublishWorkflow(\n files,\n publishMap,\n branchData.currentBranch.id,\n settings\n );\n }\n }\n}\n\nexport async function postProcessTranslations(\n settings: Settings,\n includeFiles?: Set<string>\n) {\n const postProcessIncludes = filterPostProcessIncludesForFormatTransforms(\n settings,\n includeFiles\n );\n if (includeFiles && postProcessIncludes?.size === 0) return;\n\n await postprocessMintlify(settings, postProcessIncludes);\n\n // Localize static urls (/docs -> /[locale]/docs) and preserve anchor IDs for non-default locales\n // Default locale is processed earlier in the flow in base.ts\n if (settings.options?.experimentalLocalizeStaticUrls) {\n const nonDefaultLocales = settings.locales.filter(\n (locale) => locale !== settings.defaultLocale\n );\n if (nonDefaultLocales.length > 0) {\n await localizeStaticUrls(\n settings,\n nonDefaultLocales,\n postProcessIncludes\n );\n }\n }\n\n // Rewrite relative asset URLs in translated md/mdx files\n if (settings.options?.experimentalLocalizeRelativeAssets) {\n const nonDefaultLocales = settings.locales.filter(\n (locale) => locale !== settings.defaultLocale\n );\n if (nonDefaultLocales.length > 0) {\n await localizeRelativeAssets(\n settings,\n nonDefaultLocales,\n postProcessIncludes\n );\n }\n }\n\n // Localize static imports (import Snippet from /snippets/file.mdx -> import Snippet from /snippets/[locale]/file.mdx)\n if (settings.options?.experimentalLocalizeStaticImports) {\n await localizeStaticImports(settings, postProcessIncludes);\n }\n\n const shouldProcessAnchorIds =\n settings.options?.experimentalLocalizeStaticUrls ||\n settings.options?.experimentalAddHeaderAnchorIds;\n\n // Add explicit anchor IDs to translated MDX/MD files to preserve navigation.\n // Uses inline {#id} format by default, or div wrapping if experimentalAddHeaderAnchorIds is 'mintlify'.\n //\n // Runs last of the md/mdx passes: the others re-stringify the document, which\n // re-indents headings nested in JSX and would otherwise move them out from\n // under the anchors placed here.\n if (shouldProcessAnchorIds) {\n await processAnchorIds(settings, postProcessIncludes);\n }\n\n // Flatten json files into a single file\n if (settings.options?.experimentalFlattenJsonFiles) {\n await flattenJsonFiles(settings, postProcessIncludes);\n }\n\n // Copy files to the target locale\n if (settings.options?.copyFiles) {\n await copyFile(settings);\n }\n\n // Record postprocessed content hashes for newly downloaded files\n persistPostProcessHashes(settings, postProcessIncludes, getDownloadedMeta());\n}\n\n/**\n * Exclude only outputs whose source file was translated into a different format.\n * @param settings - The settings for the project\n * @param includeFiles - The files to include in the post-processing\n * @returns The files to exclude in the post-processing\n */\nfunction filterPostProcessIncludesForFormatTransforms(\n settings: Settings,\n includeFiles?: Set<string>\n): Set<string> | undefined {\n if (!includeFiles) return includeFiles;\n\n const transformedSourcePaths = new Set<string>();\n for (const fileType of SUPPORTED_FILE_EXTENSIONS) {\n if (!hasNonIdentityFileFormatTransformForType(settings, fileType)) continue;\n\n for (const sourcePath of settings.files.resolvedPaths[fileType] || []) {\n transformedSourcePaths.add(getRelative(sourcePath));\n }\n }\n if (transformedSourcePaths.size === 0) return includeFiles;\n\n const { resolvedPaths, placeholderPaths, transformPaths, transformFormats } =\n settings.files;\n const fileMapping = createFileMapping(\n resolvedPaths,\n placeholderPaths,\n transformPaths,\n transformFormats,\n settings.locales,\n settings.defaultLocale\n );\n\n const transformedOutputPaths = new Set<string>();\n for (const localeMapping of Object.values(fileMapping)) {\n for (const [sourcePath, outputPath] of Object.entries(localeMapping)) {\n if (transformedSourcePaths.has(sourcePath)) {\n transformedOutputPaths.add(outputPath);\n }\n }\n }\n\n return new Set(\n [...includeFiles].filter(\n (filePath) => !transformedOutputPaths.has(filePath)\n )\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAyBA,eAAsB,gBACpB,SACA,UACA,iBACA,SACA,YACA,YACA,eACA;AACA,KAAI,iBAAiB;EACnB,MAAM,EACJ,eACA,kBACA,gBACA,qBACE,SAAS;EAEb,MAAM,cAAc,kBAClB,eACA,kBACA,gBACA,kBACA,SAAS,SACT,SAAS,cACV;AAED,QAAM,oBAAoB;GACP;GACR;GACG;GACZ,SAAS,SAAS;GAClB,iBAAiB,QAAQ;GACzB,oBAAoB,YAAY,WAC9B,YAAY,UAAU,eAAe;GACvC,SAAS;GACT;GACA,oBAAoB,QAAQ;GAC5B,eAAe,QAAQ,iBAAiB,QAAQ;GACjD,CAAC;AAGF,MAAI,cAAc,YAAY,cAAc,GAM1C,OAAM,mBALQ,OAAO,QAAQ,gBAAgB,CAAC,KAAK,CAAC,QAAQ,WAAW;GACrE;GACA,WAAW,KAAK;GAChB,UAAU,KAAK;GAChB,EAEM,EACL,YACA,WAAW,cAAc,IACzB,SACD;;;AAKP,eAAsB,wBACpB,UACA,cACA;CACA,MAAM,sBAAsB,6CAC1B,UACA,aACD;AACD,KAAI,gBAAgB,qBAAqB,SAAS,EAAG;AAErD,OAAM,oBAAoB,UAAU,oBAAoB;AAIxD,KAAI,SAAS,SAAS,gCAAgC;EACpD,MAAM,oBAAoB,SAAS,QAAQ,QACxC,WAAW,WAAW,SAAS,cACjC;AACD,MAAI,kBAAkB,SAAS,EAC7B,OAAM,mBACJ,UACA,mBACA,oBACD;;AAKL,KAAI,SAAS,SAAS,oCAAoC;EACxD,MAAM,oBAAoB,SAAS,QAAQ,QACxC,WAAW,WAAW,SAAS,cACjC;AACD,MAAI,kBAAkB,SAAS,EAC7B,OAAM,uBACJ,UACA,mBACA,oBACD;;AAKL,KAAI,SAAS,SAAS,kCACpB,OAAM,sBAAsB,UAAU,oBAAoB;AAa5D,KATE,SAAS,SAAS,kCAClB,SAAS,SAAS,+BASlB,OAAM,iBAAiB,UAAU,oBAAoB;AAIvD,KAAI,SAAS,SAAS,6BACpB,OAAM,iBAAiB,UAAU,oBAAoB;AAIvD,KAAI,SAAS,SAAS,UACpB,OAAM,SAAS,SAAS;AAI1B,0BAAyB,UAAU,qBAAqB,mBAAmB,CAAC;;;;;;;;AAS9E,SAAS,6CACP,UACA,cACyB;AACzB,KAAI,CAAC,aAAc,QAAO;CAE1B,MAAM,yCAAyB,IAAI,KAAa;AAChD,MAAK,MAAM,YAAY,2BAA2B;AAChD,MAAI,CAAC,yCAAyC,UAAU,SAAS,CAAE;AAEnE,OAAK,MAAM,cAAc,SAAS,MAAM,cAAc,aAAa,EAAE,CACnE,wBAAuB,IAAI,YAAY,WAAW,CAAC;;AAGvD,KAAI,uBAAuB,SAAS,EAAG,QAAO;CAE9C,MAAM,EAAE,eAAe,kBAAkB,gBAAgB,qBACvD,SAAS;CACX,MAAM,cAAc,kBAClB,eACA,kBACA,gBACA,kBACA,SAAS,SACT,SAAS,cACV;CAED,MAAM,yCAAyB,IAAI,KAAa;AAChD,MAAK,MAAM,iBAAiB,OAAO,OAAO,YAAY,CACpD,MAAK,MAAM,CAAC,YAAY,eAAe,OAAO,QAAQ,cAAc,CAClE,KAAI,uBAAuB,IAAI,WAAW,CACxC,wBAAuB,IAAI,WAAW;AAK5C,QAAO,IAAI,IACT,CAAC,GAAG,aAAa,CAAC,QACf,aAAa,CAAC,uBAAuB,IAAI,SAAS,CACpD,CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const PACKAGE_VERSION = "2.17.
|
|
1
|
+
export declare const PACKAGE_VERSION = "2.17.3";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"version.js","names":[],"sources":["../../src/generated/version.ts"],"sourcesContent":["// This file is auto-generated. Do not edit manually.\nexport const PACKAGE_VERSION = '2.17.
|
|
1
|
+
{"version":3,"file":"version.js","names":[],"sources":["../../src/generated/version.ts"],"sourcesContent":["// This file is auto-generated. Do not edit manually.\nexport const PACKAGE_VERSION = '2.17.3';\n"],"mappings":";AACA,MAAa,kBAAkB"}
|
|
@@ -2,6 +2,12 @@ import type { AdditionalOptions } from '../types/index.js';
|
|
|
2
2
|
type AnchorIdSettings = {
|
|
3
3
|
options?: Pick<AdditionalOptions, 'experimentalAddHeaderAnchorIds'>;
|
|
4
4
|
};
|
|
5
|
+
/** A source range on a single line, as 1-based inclusive/exclusive columns. */
|
|
6
|
+
interface ColumnRange {
|
|
7
|
+
line: number;
|
|
8
|
+
startColumn: number;
|
|
9
|
+
endColumn: number;
|
|
10
|
+
}
|
|
5
11
|
/**
|
|
6
12
|
* Represents a heading with its position and metadata
|
|
7
13
|
*/
|
|
@@ -10,9 +16,23 @@ export interface HeadingInfo {
|
|
|
10
16
|
level: number;
|
|
11
17
|
slug: string;
|
|
12
18
|
position: number;
|
|
19
|
+
/** 1-based line the heading starts on. */
|
|
20
|
+
startLine: number;
|
|
21
|
+
/** 1-based line the heading ends on (differs from startLine for setext). */
|
|
22
|
+
endLine: number;
|
|
23
|
+
/** 1-based column of the heading marker; anything left of it is indentation. */
|
|
24
|
+
startColumn: number;
|
|
25
|
+
/** 1-based column just past the text, before any closing `##`; -1 if unknown. */
|
|
26
|
+
textEndColumn: number;
|
|
27
|
+
/** `id` attribute of a wrapper element already anchoring this heading. */
|
|
28
|
+
wrapperId: ColumnRange | null;
|
|
29
|
+
/** Whether the author wrote an explicit `{#id}`. */
|
|
30
|
+
explicit: boolean;
|
|
13
31
|
}
|
|
14
32
|
/**
|
|
15
|
-
* Extracts heading information from content (read-only, no modifications)
|
|
33
|
+
* Extracts heading information from content (read-only, no modifications).
|
|
34
|
+
* Source and translation are matched by position, so both must parse the same
|
|
35
|
+
* way — the fallback extractor misses headings nested in JSX.
|
|
16
36
|
*/
|
|
17
37
|
export declare function extractHeadingInfo(mdxContent: string): HeadingInfo[];
|
|
18
38
|
/**
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { logger } from "../console/logger.js";
|
|
2
|
-
import {
|
|
3
|
-
import remarkParse from "remark-parse";
|
|
4
|
-
import remarkMdx from "remark-mdx";
|
|
5
|
-
import remarkFrontmatter from "remark-frontmatter";
|
|
2
|
+
import { forEachLineOutsideCodeFences, mapLinesOutsideCodeFences, parseMdxTolerantly } from "./mdxAnchorSyntax.js";
|
|
6
3
|
import { visit } from "unist-util-visit";
|
|
7
|
-
import remarkStringify from "remark-stringify";
|
|
8
|
-
import { escapeHtmlInTextNodes, normalizeCJKCharacters } from "gt-remark";
|
|
9
|
-
import { decode } from "html-entities";
|
|
10
4
|
//#region src/utils/addExplicitAnchorIds.ts
|
|
5
|
+
/** An ATX heading line, split into indentation, marker and text. */
|
|
6
|
+
const ATX_HEADING = /^([ \t]*)(#{1,6}[ \t]+)(.*)$/;
|
|
7
|
+
/** A trailing custom anchor ID, in either the plain or MDX-escaped form. */
|
|
8
|
+
const TRAILING_ANCHOR = /\s*(?:\\\{#[^}]+\\\}|\{#[^}]+\})\s*$/;
|
|
11
9
|
/**
|
|
12
10
|
* Generates a slug from heading text
|
|
13
11
|
*/
|
|
@@ -25,41 +23,31 @@ function extractHeadingText(heading) {
|
|
|
25
23
|
return text;
|
|
26
24
|
}
|
|
27
25
|
/**
|
|
28
|
-
*
|
|
29
|
-
* Used as a fallback when MDX parsing fails.
|
|
26
|
+
* Line-by-line heading extractor used when MDX parsing fails outright.
|
|
30
27
|
*/
|
|
31
28
|
function extractHeadingsWithFallback(mdxContent) {
|
|
32
29
|
const headings = [];
|
|
33
|
-
const lines = mdxContent.split("\n");
|
|
34
30
|
let position = 0;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
if (fenceMatch) {
|
|
40
|
-
const fenceString = fenceMatch[2];
|
|
41
|
-
if (!inFence) {
|
|
42
|
-
inFence = true;
|
|
43
|
-
fenceChar = fenceString;
|
|
44
|
-
} else if (fenceChar && fenceString[0] === fenceChar[0] && fenceString.length >= fenceChar.length) {
|
|
45
|
-
inFence = false;
|
|
46
|
-
fenceChar = null;
|
|
47
|
-
}
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
if (inFence) continue;
|
|
51
|
-
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
52
|
-
if (!headingMatch) continue;
|
|
53
|
-
const hashes = headingMatch[1];
|
|
54
|
-
const rawText = headingMatch[2];
|
|
31
|
+
forEachLineOutsideCodeFences(mdxContent, (line, index) => {
|
|
32
|
+
const headingMatch = line.match(ATX_HEADING);
|
|
33
|
+
if (!headingMatch) return;
|
|
34
|
+
const [, indent, marker, rawText] = headingMatch;
|
|
55
35
|
const { cleanedText, explicitId } = parseHeadingContent(rawText);
|
|
56
|
-
if (cleanedText
|
|
36
|
+
if (!cleanedText && !explicitId) return;
|
|
37
|
+
headings.push({
|
|
57
38
|
text: cleanedText,
|
|
58
|
-
level:
|
|
39
|
+
level: marker.trim().length,
|
|
59
40
|
slug: explicitId ?? generateSlug(cleanedText),
|
|
60
|
-
position: position
|
|
41
|
+
position: position++,
|
|
42
|
+
startLine: index + 1,
|
|
43
|
+
endLine: index + 1,
|
|
44
|
+
startColumn: indent.length + 1,
|
|
45
|
+
textEndColumn: line.length + 1,
|
|
46
|
+
wrapperId: null,
|
|
47
|
+
explicit: explicitId !== void 0
|
|
61
48
|
});
|
|
62
|
-
}
|
|
49
|
+
});
|
|
50
|
+
assignUniqueSlugs(headings);
|
|
63
51
|
return headings;
|
|
64
52
|
}
|
|
65
53
|
function parseHeadingContent(text) {
|
|
@@ -72,39 +60,72 @@ function parseHeadingContent(text) {
|
|
|
72
60
|
};
|
|
73
61
|
}
|
|
74
62
|
/**
|
|
75
|
-
*
|
|
63
|
+
* Suffixes repeated slugs `-2`, `-3`, ... as Mintlify does. Author-written IDs
|
|
64
|
+
* are never renumbered, only reserved.
|
|
65
|
+
*/
|
|
66
|
+
function assignUniqueSlugs(headings) {
|
|
67
|
+
const used = new Set(headings.filter((heading) => heading.explicit).map((heading) => heading.slug));
|
|
68
|
+
for (const heading of headings) {
|
|
69
|
+
if (heading.explicit) continue;
|
|
70
|
+
const base = heading.slug || "section";
|
|
71
|
+
let slug = base;
|
|
72
|
+
let suffix = 1;
|
|
73
|
+
while (used.has(slug)) {
|
|
74
|
+
suffix += 1;
|
|
75
|
+
slug = `${base}-${suffix}`;
|
|
76
|
+
}
|
|
77
|
+
heading.slug = slug;
|
|
78
|
+
used.add(slug);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Finds the `id` of a wrapper element already anchoring this heading. Requiring
|
|
83
|
+
* the heading to be its only child rules out containers like `<Tab>`.
|
|
76
84
|
*/
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return
|
|
85
|
+
function findWrapperId(heading, parent) {
|
|
86
|
+
if (!parent || parent.type !== "mdxJsxFlowElement") return null;
|
|
87
|
+
const element = parent;
|
|
88
|
+
if (element.children.length !== 1 || element.children[0] !== heading) return null;
|
|
89
|
+
const position = element.attributes.find((attribute) => attribute.type === "mdxJsxAttribute" && attribute.name === "id")?.position;
|
|
90
|
+
if (!position || position.start.line !== position.end.line) return null;
|
|
91
|
+
return {
|
|
92
|
+
line: position.start.line,
|
|
93
|
+
startColumn: position.start.column,
|
|
94
|
+
endColumn: position.end.column
|
|
95
|
+
};
|
|
81
96
|
}
|
|
82
97
|
/**
|
|
83
|
-
* Extracts heading information from content (read-only, no modifications)
|
|
98
|
+
* Extracts heading information from content (read-only, no modifications).
|
|
99
|
+
* Source and translation are matched by position, so both must parse the same
|
|
100
|
+
* way — the fallback extractor misses headings nested in JSX.
|
|
84
101
|
*/
|
|
85
102
|
function extractHeadingInfo(mdxContent) {
|
|
86
|
-
|
|
87
|
-
let processedAst;
|
|
103
|
+
let ast;
|
|
88
104
|
try {
|
|
89
|
-
|
|
90
|
-
const ast = parseProcessor.parse(mdxContent);
|
|
91
|
-
processedAst = parseProcessor.runSync(ast);
|
|
105
|
+
ast = parseMdxTolerantly(mdxContent);
|
|
92
106
|
} catch {
|
|
93
107
|
return extractHeadingsWithFallback(mdxContent);
|
|
94
108
|
}
|
|
109
|
+
const headings = [];
|
|
95
110
|
let position = 0;
|
|
96
|
-
visit(
|
|
111
|
+
visit(ast, "heading", (heading, _index, parent) => {
|
|
97
112
|
const { cleanedText, explicitId } = parseHeadingContent(extractHeadingText(heading));
|
|
98
|
-
if (cleanedText
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
if (!cleanedText && !explicitId) return;
|
|
114
|
+
const lastChild = heading.children[heading.children.length - 1];
|
|
115
|
+
headings.push({
|
|
116
|
+
text: cleanedText,
|
|
117
|
+
level: heading.depth,
|
|
118
|
+
slug: explicitId ?? generateSlug(cleanedText),
|
|
119
|
+
position: position++,
|
|
120
|
+
startLine: heading.position?.start.line ?? -1,
|
|
121
|
+
endLine: heading.position?.end.line ?? -1,
|
|
122
|
+
startColumn: heading.position?.start.column ?? 1,
|
|
123
|
+
textEndColumn: lastChild?.position?.end.column ?? heading.position?.end.column ?? -1,
|
|
124
|
+
wrapperId: findWrapperId(heading, parent),
|
|
125
|
+
explicit: explicitId !== void 0
|
|
126
|
+
});
|
|
107
127
|
});
|
|
128
|
+
assignUniqueSlugs(headings);
|
|
108
129
|
return headings;
|
|
109
130
|
}
|
|
110
131
|
/**
|
|
@@ -123,23 +144,28 @@ function addExplicitAnchorIds(translatedContent, sourceHeadingMap, settings, sou
|
|
|
123
144
|
sourceHeadingMap.forEach((sourceHeading, index) => {
|
|
124
145
|
const translatedHeading = translatedHeadings[index];
|
|
125
146
|
if (translatedHeading && translatedHeading.level === sourceHeading.level) {
|
|
126
|
-
idMappings.set(index,
|
|
147
|
+
idMappings.set(index, {
|
|
148
|
+
id: sourceHeading.slug,
|
|
149
|
+
explicit: sourceHeading.explicit
|
|
150
|
+
});
|
|
127
151
|
addedIds.push({
|
|
128
152
|
heading: translatedHeading.text,
|
|
129
153
|
id: sourceHeading.slug
|
|
130
154
|
});
|
|
131
155
|
}
|
|
132
156
|
});
|
|
133
|
-
if (idMappings.size === 0) return {
|
|
134
|
-
content: translatedContent,
|
|
135
|
-
hasChanges: false,
|
|
136
|
-
addedIds: []
|
|
137
|
-
};
|
|
138
157
|
const translatedIsMdx = translatedPath ? translatedPath.toLowerCase().endsWith(".mdx") : true;
|
|
139
158
|
const shouldEscapeAnchors = fileTypeHint === "mdx" ? true : fileTypeHint === "md" ? false : translatedIsMdx;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
if (idMappings.size === 0) {
|
|
160
|
+
const content = useDivWrapping ? translatedContent : normalizeInlineAnchors(translatedContent, shouldEscapeAnchors);
|
|
161
|
+
return {
|
|
162
|
+
content,
|
|
163
|
+
hasChanges: content !== translatedContent,
|
|
164
|
+
addedIds: []
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
let content = applyAnchorIds(translatedContent, translatedHeadings, idMappings, useDivWrapping, shouldEscapeAnchors);
|
|
168
|
+
if (!useDivWrapping) content = normalizeInlineAnchors(content, shouldEscapeAnchors);
|
|
143
169
|
return {
|
|
144
170
|
content,
|
|
145
171
|
hasChanges: content !== translatedContent,
|
|
@@ -147,149 +173,55 @@ function addExplicitAnchorIds(translatedContent, sourceHeadingMap, settings, sou
|
|
|
147
173
|
};
|
|
148
174
|
}
|
|
149
175
|
/**
|
|
150
|
-
*
|
|
176
|
+
* Writes anchor IDs onto the translated document, locating headings by parser
|
|
177
|
+
* line position rather than by text. Edits run bottom-up to keep lines valid.
|
|
151
178
|
*/
|
|
152
|
-
function
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return applyInlineIdsStringFallback(translatedContent, idMappings, escapeAnchors);
|
|
167
|
-
}
|
|
168
|
-
let headingIndex = 0;
|
|
169
|
-
let actuallyModifiedContent = false;
|
|
170
|
-
visit(processedAst, "heading", (heading) => {
|
|
171
|
-
const id = idMappings.get(headingIndex);
|
|
172
|
-
if (id) {
|
|
173
|
-
if (hasExplicitId(heading, processedAst)) {
|
|
174
|
-
if (escapeAnchors) {
|
|
175
|
-
const lastChild = heading.children[heading.children.length - 1];
|
|
176
|
-
if (lastChild?.type === "text") {
|
|
177
|
-
const match = lastChild.value.match(/\{#([^}]+)\}\s*$/);
|
|
178
|
-
const alreadyEscaped = lastChild.value.match(/\\\{#[^}]+\\\}\s*$/);
|
|
179
|
-
if (match && !alreadyEscaped) {
|
|
180
|
-
const anchorId = match[1];
|
|
181
|
-
lastChild.value = `${lastChild.value.replace(/\s*\{#[^}]+\}\s*$/, "")} \\{#${anchorId}\\}`;
|
|
182
|
-
actuallyModifiedContent = true;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
headingIndex++;
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
const lastChild = heading.children[heading.children.length - 1];
|
|
190
|
-
if (lastChild?.type === "text") lastChild.value += escapeAnchors ? ` \\{#${id}\\}` : ` {#${id}}`;
|
|
191
|
-
else heading.children.push({
|
|
192
|
-
type: "text",
|
|
193
|
-
value: escapeAnchors ? ` \\{#${id}\\}` : ` {#${id}}`
|
|
194
|
-
});
|
|
195
|
-
actuallyModifiedContent = true;
|
|
196
|
-
}
|
|
197
|
-
headingIndex++;
|
|
198
|
-
});
|
|
199
|
-
if (!actuallyModifiedContent) return escapeInlineAnchors(translatedContent);
|
|
200
|
-
try {
|
|
201
|
-
const stringifyProcessor = unified().use(remarkFrontmatter, ["yaml", "toml"]).use(remarkMdx).use(normalizeCJKCharacters).use(escapeHtmlInTextNodes).use(remarkStringify, { handlers: { text(node) {
|
|
202
|
-
return node.value;
|
|
203
|
-
} } });
|
|
204
|
-
const outTree = stringifyProcessor.runSync(processedAst);
|
|
205
|
-
let content = stringifyProcessor.stringify(outTree);
|
|
206
|
-
if (content.endsWith("\n") && !translatedContent.endsWith("\n")) content = content.slice(0, -1);
|
|
207
|
-
if (translatedContent.startsWith("\n") && !content.startsWith("\n")) content = "\n" + content;
|
|
208
|
-
return content;
|
|
209
|
-
} catch {
|
|
210
|
-
return translatedContent;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
/**
|
|
214
|
-
* Fallback string-based inline ID application when AST parsing fails
|
|
215
|
-
*/
|
|
216
|
-
function applyInlineIdsStringFallback(translatedContent, idMappings, escapeAnchors) {
|
|
217
|
-
let headingIndex = 0;
|
|
218
|
-
let inFence = false;
|
|
219
|
-
let fenceChar = null;
|
|
220
|
-
return translatedContent.split("\n").map((line) => {
|
|
221
|
-
const fenceMatch = line.match(/^(\s*)(`{3,}|~{3,})/);
|
|
222
|
-
if (fenceMatch) {
|
|
223
|
-
const fenceString = fenceMatch[2];
|
|
224
|
-
if (!inFence) {
|
|
225
|
-
inFence = true;
|
|
226
|
-
fenceChar = fenceString;
|
|
227
|
-
} else if (fenceChar && fenceString[0] === fenceChar[0] && fenceString.length >= fenceChar.length) {
|
|
228
|
-
inFence = false;
|
|
229
|
-
fenceChar = null;
|
|
230
|
-
}
|
|
231
|
-
return line;
|
|
179
|
+
function applyAnchorIds(translatedContent, translatedHeadings, idMappings, useDivWrapping, escapeAnchors) {
|
|
180
|
+
const lines = translatedContent.split("\n");
|
|
181
|
+
const ordered = [...translatedHeadings].sort((a, b) => b.startLine - a.startLine);
|
|
182
|
+
for (const heading of ordered) {
|
|
183
|
+
const mapping = idMappings.get(heading.position);
|
|
184
|
+
if (!mapping) continue;
|
|
185
|
+
if (heading.startLine < 1 || heading.endLine > lines.length) continue;
|
|
186
|
+
const index = heading.startLine - 1;
|
|
187
|
+
if (!useDivWrapping || mapping.explicit) {
|
|
188
|
+
if (heading.textEndColumn < 1) continue;
|
|
189
|
+
const anchor = escapeAnchors && !mapping.explicit ? `\\{#${mapping.id}\\}` : `{#${mapping.id}}`;
|
|
190
|
+
const line = lines[index];
|
|
191
|
+
lines[index] = `${line.slice(0, heading.textEndColumn - 1).replace(TRAILING_ANCHOR, "")} ${anchor}${line.slice(heading.textEndColumn - 1)}`;
|
|
192
|
+
continue;
|
|
232
193
|
}
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const id = idMappings.get(headingIndex++);
|
|
239
|
-
if (!id) return line;
|
|
240
|
-
const hasEscaped = /\\\{#[^}]+\\\}\s*$/.test(text);
|
|
241
|
-
const hasUnescaped = /\{#[^}]+\}\s*$/.test(text);
|
|
242
|
-
if (hasEscaped) return line;
|
|
243
|
-
if (hasUnescaped) {
|
|
244
|
-
if (!escapeAnchors) return line;
|
|
245
|
-
return `${prefix}${text.replace(/\{#([^}]+)\}\s*$/, "\\{#$1\\}")}`;
|
|
194
|
+
if (heading.wrapperId) {
|
|
195
|
+
const { line, startColumn, endColumn } = heading.wrapperId;
|
|
196
|
+
const wrapper = lines[line - 1];
|
|
197
|
+
lines[line - 1] = wrapper.slice(0, startColumn - 1) + `id="${mapping.id}"` + wrapper.slice(endColumn - 1);
|
|
198
|
+
continue;
|
|
246
199
|
}
|
|
247
|
-
|
|
248
|
-
|
|
200
|
+
const indent = lines[index].slice(0, Math.max(0, heading.startColumn - 1));
|
|
201
|
+
const body = lines.slice(index, heading.endLine).map((line) => ` ${line}`);
|
|
202
|
+
lines.splice(index, heading.endLine - heading.startLine + 1, `${indent}<div id="${mapping.id}">`, ...body, `${indent}</div>`);
|
|
203
|
+
}
|
|
204
|
+
return lines.join("\n");
|
|
249
205
|
}
|
|
250
206
|
/**
|
|
251
|
-
*
|
|
207
|
+
* Normalizes every inline anchor: escaped for MDX, bare for Markdown.
|
|
252
208
|
*/
|
|
253
|
-
function
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
level,
|
|
263
|
-
index
|
|
264
|
-
});
|
|
209
|
+
function normalizeInlineAnchors(content, escapeAnchors) {
|
|
210
|
+
return mapLinesOutsideCodeFences(content, (line) => {
|
|
211
|
+
const atx = line.match(ATX_HEADING);
|
|
212
|
+
if (!atx) return line;
|
|
213
|
+
const escaped = atx[3].match(/\\\{#([A-Za-z0-9_-]+)\\\}\s*$/);
|
|
214
|
+
const bare = atx[3].match(/(?<!\\)\{#([A-Za-z0-9_-]+)\}\s*$/);
|
|
215
|
+
if (escapeAnchors && bare) {
|
|
216
|
+
const text = atx[3].replace(TRAILING_ANCHOR, "");
|
|
217
|
+
return `${atx[1]}${atx[2]}${text} \\{#${bare[1]}\\}`;
|
|
265
218
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
translatedHeadings.forEach((heading, position) => {
|
|
270
|
-
const id = idMappings.get(position);
|
|
271
|
-
if (id) {
|
|
272
|
-
const matchingLine = headingLines.find((hl) => {
|
|
273
|
-
return decode(hl.line.replace(/^#{1,6}\s+/, "").trim().replace(/\*\*(.*?)\*\*/g, "$1").replace(/\*(.*?)\*/g, "$1").replace(/`(.*?)`/g, "$1").replace(/\[(.*?)\]\(.*?\)/g, "$1").trim()).trim() === decode(heading.text).trim() && hl.level === heading.level;
|
|
274
|
-
});
|
|
275
|
-
if (matchingLine) headingsToWrap.push({
|
|
276
|
-
originalLine: matchingLine.line,
|
|
277
|
-
id
|
|
278
|
-
});
|
|
219
|
+
if (!escapeAnchors && escaped) {
|
|
220
|
+
const text = atx[3].replace(TRAILING_ANCHOR, "");
|
|
221
|
+
return `${atx[1]}${atx[2]}${text} {#${escaped[1]}}`;
|
|
279
222
|
}
|
|
223
|
+
return line;
|
|
280
224
|
});
|
|
281
|
-
if (headingsToWrap.length > 0) {
|
|
282
|
-
const sortedHeadings = headingsToWrap.sort((a, b) => b.originalLine.length - a.originalLine.length);
|
|
283
|
-
for (const heading of sortedHeadings) {
|
|
284
|
-
if (content.includes(`<div id="${heading.id}">`)) continue;
|
|
285
|
-
const escapedLine = heading.originalLine.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
286
|
-
const headingPattern = new RegExp(`^${escapedLine}\\s*$`, "gm");
|
|
287
|
-
content = content.replace(headingPattern, (match) => {
|
|
288
|
-
return `<div id="${heading.id}">\n ${match.trim()}\n</div>\n`;
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return content;
|
|
293
225
|
}
|
|
294
226
|
//#endregion
|
|
295
227
|
export { addExplicitAnchorIds, extractHeadingInfo };
|