fix(i18n): share Markdown link parsing
This commit is contained in:
parent
8729f38015
commit
08ec2622b0
5 changed files with 164 additions and 238 deletions
|
|
@ -6,7 +6,14 @@ import {
|
||||||
renderInheritedPage,
|
renderInheritedPage,
|
||||||
renderPageRegion,
|
renderPageRegion,
|
||||||
} from '../src/cordis-catalog.ts'
|
} from '../src/cordis-catalog.ts'
|
||||||
import { CORDIS_CATALOG_POLICY, EVENT_SCOPE_PAGE, REGION_BEGIN, REGION_END, SERVICE_PAGE } from '../../../../scripts/gen-cordis-catalog.ts'
|
import {
|
||||||
|
CORDIS_CATALOG_POLICY,
|
||||||
|
EVENT_SCOPE_PAGE,
|
||||||
|
localizePageRegion,
|
||||||
|
REGION_BEGIN,
|
||||||
|
REGION_END,
|
||||||
|
SERVICE_PAGE,
|
||||||
|
} from '../../../../scripts/gen-cordis-catalog.ts'
|
||||||
|
|
||||||
const workspaceRoot = resolve(import.meta.dirname, '../../../..')
|
const workspaceRoot = resolve(import.meta.dirname, '../../../..')
|
||||||
|
|
||||||
|
|
@ -29,11 +36,14 @@ describe('Typert-backed Cordis catalog', () => {
|
||||||
CORDIS_CATALOG_POLICY,
|
CORDIS_CATALOG_POLICY,
|
||||||
)
|
)
|
||||||
for (const side of [page, page.replace(/\.md$/, '.zh.md')]) {
|
for (const side of [page, page.replace(/\.md$/, '.zh.md')]) {
|
||||||
const committed = expected(`docs/subsystems/${side}`)
|
const rel = `docs/subsystems/${side}`
|
||||||
|
const committed = expected(rel)
|
||||||
const begin = committed.indexOf(REGION_BEGIN)
|
const begin = committed.indexOf(REGION_BEGIN)
|
||||||
const end = committed.indexOf(REGION_END)
|
const end = committed.indexOf(REGION_END)
|
||||||
expect(begin, `docs/subsystems/${side} carries the region`).toBeGreaterThanOrEqual(0)
|
expect(begin, `${rel} carries the region`).toBeGreaterThanOrEqual(0)
|
||||||
expect(committed.slice(begin, end + REGION_END.length)).toBe(region)
|
expect(committed.slice(begin, end + REGION_END.length)).toBe(
|
||||||
|
localizePageRegion(region, rel, workspaceRoot),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(projector.renderRuntimeApi(model)).toBe(
|
expect(projector.renderRuntimeApi(model)).toBe(
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,101 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Markdown nodes whose authored destination occupies a replaceable source range. */
|
||||||
|
export type MarkdownDestinationNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
|
||||||
|
|
||||||
|
/** One authored Markdown destination and its absolute source offsets. */
|
||||||
|
export interface MarkdownDestination {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a Markdown URL is external, repository-root absolute, or purely in-page. */
|
||||||
|
export function isExternalOrAbsoluteMarkdownUrl(url: string): boolean {
|
||||||
|
return url.startsWith('#')
|
||||||
|
|| url.startsWith('//')
|
||||||
|
|| url.startsWith('/')
|
||||||
|
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split one Markdown URL without normalizing its query or fragment suffix. */
|
||||||
|
export function splitMarkdownUrlTarget(url: string): { path: string; suffix: string } {
|
||||||
|
const boundary = url.search(/[?#]/)
|
||||||
|
if (boundary === -1) return { path: url, suffix: '' }
|
||||||
|
return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipWhitespace(source: string, start: number): number {
|
||||||
|
let index = start
|
||||||
|
while (/\s/.test(source[index] ?? '')) index += 1
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelEnd(source: string): number {
|
||||||
|
const first = source.indexOf('[')
|
||||||
|
if (first === -1) return -1
|
||||||
|
let depth = 0
|
||||||
|
for (let index = first; index < source.length; index += 1) {
|
||||||
|
const char = source[index]
|
||||||
|
if (char === '\\') index += 1
|
||||||
|
else if (char === '[') depth += 1
|
||||||
|
else if (char === ']') {
|
||||||
|
depth -= 1
|
||||||
|
if (depth === 0) return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function destinationRange(rawNode: string, type: MarkdownDestinationNode['type']): { start: number; end: number } {
|
||||||
|
const endOfLabel = labelEnd(rawNode)
|
||||||
|
if (endOfLabel === -1) throw new Error(`markdown: cannot locate label end in ${JSON.stringify(rawNode)}`)
|
||||||
|
let start: number
|
||||||
|
if (type === 'definition') {
|
||||||
|
const colon = rawNode.indexOf(':', endOfLabel + 1)
|
||||||
|
if (colon === -1) throw new Error(`markdown: cannot locate definition separator in ${JSON.stringify(rawNode)}`)
|
||||||
|
start = skipWhitespace(rawNode, colon + 1)
|
||||||
|
} else {
|
||||||
|
if (rawNode[endOfLabel + 1] !== '(') {
|
||||||
|
throw new Error(`markdown: cannot locate inline destination in ${JSON.stringify(rawNode)}`)
|
||||||
|
}
|
||||||
|
start = skipWhitespace(rawNode, endOfLabel + 2)
|
||||||
|
}
|
||||||
|
if (rawNode[start] === '<') {
|
||||||
|
for (let index = start + 1; index < rawNode.length; index += 1) {
|
||||||
|
if (rawNode[index] === '\\') index += 1
|
||||||
|
else if (rawNode[index] === '>') return { start: start + 1, end: index }
|
||||||
|
}
|
||||||
|
throw new Error(`markdown: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}`)
|
||||||
|
}
|
||||||
|
let depth = 0
|
||||||
|
for (let index = start; index < rawNode.length; index += 1) {
|
||||||
|
const char = rawNode[index]
|
||||||
|
if (char === '\\') index += 1
|
||||||
|
else if (char === '(') depth += 1
|
||||||
|
else if (char === ')') {
|
||||||
|
if (depth === 0) return { start, end: index }
|
||||||
|
depth -= 1
|
||||||
|
} else if (/\s/.test(char ?? '') && depth === 0) {
|
||||||
|
return { start, end: index }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start, end: rawNode.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locate one parsed destination in the original Markdown without reserializing it. */
|
||||||
|
export function markdownDestination(source: string, node: MarkdownDestinationNode): MarkdownDestination {
|
||||||
|
const start = node.position?.start.offset
|
||||||
|
const end = node.position?.end.offset
|
||||||
|
if (start === undefined || end === undefined) {
|
||||||
|
throw new Error(`markdown: destination ${JSON.stringify(node.url)} has no source offsets`)
|
||||||
|
}
|
||||||
|
const range = destinationRange(source.slice(start, end), node.type)
|
||||||
|
const absolute = { start: start + range.start, end: start + range.end }
|
||||||
|
return { ...absolute, url: source.slice(absolute.start, absolute.end) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract every parsed code block with its info string, in document order.
|
* Extract every parsed code block with its info string, in document order.
|
||||||
* @param source - Markdown source to scan.
|
* @param source - Markdown source to scan.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||||
import { gfm } from 'micromark-extension-gfm'
|
import { gfm } from 'micromark-extension-gfm'
|
||||||
import type { Nodes } from 'mdast'
|
import type { Nodes } from 'mdast'
|
||||||
import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
|
import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts'
|
||||||
|
import {
|
||||||
|
isExternalOrAbsoluteMarkdownUrl,
|
||||||
|
markdownDestination,
|
||||||
|
splitMarkdownUrlTarget,
|
||||||
|
} from './markdown.ts'
|
||||||
|
|
||||||
const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness'
|
const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness'
|
||||||
const root = resolve(import.meta.dirname, '..')
|
const root = resolve(import.meta.dirname, '..')
|
||||||
|
|
@ -35,11 +40,6 @@ interface Replacement {
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DestinationRange {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
|
type RewritableNode = Extract<Nodes, { type: 'link' | 'image' | 'definition' }>
|
||||||
|
|
||||||
/** Inputs for rewriting one canonical Markdown page. */
|
/** Inputs for rewriting one canonical Markdown page. */
|
||||||
|
|
@ -65,94 +65,12 @@ function repoPath(absPath: string, repoRoot: string): string {
|
||||||
return relative(repoRoot, absPath).split(sep).join('/')
|
return relative(repoRoot, absPath).split(sep).join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExternalOrSiteAbsolute(url: string): boolean {
|
|
||||||
return url.startsWith('#')
|
|
||||||
|| url.startsWith('//')
|
|
||||||
|| url.startsWith('/')
|
|
||||||
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
function skipWhitespace(source: string, start: number): number {
|
|
||||||
let index = start
|
|
||||||
while (/\s/.test(source[index] ?? '')) index += 1
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelEnd(source: string): number {
|
|
||||||
const first = source.indexOf('[')
|
|
||||||
if (first === -1) return -1
|
|
||||||
let depth = 0
|
|
||||||
for (let index = first; index < source.length; index += 1) {
|
|
||||||
const char = source[index]
|
|
||||||
if (char === '\\') {
|
|
||||||
index += 1
|
|
||||||
} else if (char === '[') {
|
|
||||||
depth += 1
|
|
||||||
} else if (char === ']') {
|
|
||||||
depth -= 1
|
|
||||||
if (depth === 0) return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
function destinationRange(rawNode: string, type: 'link' | 'image' | 'definition'): DestinationRange {
|
|
||||||
const endOfLabel = labelEnd(rawNode)
|
|
||||||
if (endOfLabel === -1) {
|
|
||||||
throw new Error(`project-doc-site: cannot locate label end in ${JSON.stringify(rawNode)}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let start: number
|
|
||||||
if (type === 'definition') {
|
|
||||||
const colon = rawNode.indexOf(':', endOfLabel + 1)
|
|
||||||
if (colon === -1) {
|
|
||||||
throw new Error(`project-doc-site: cannot locate definition separator in ${JSON.stringify(rawNode)}.`)
|
|
||||||
}
|
|
||||||
start = skipWhitespace(rawNode, colon + 1)
|
|
||||||
} else {
|
|
||||||
if (rawNode[endOfLabel + 1] !== '(') {
|
|
||||||
throw new Error(`project-doc-site: cannot locate inline destination in ${JSON.stringify(rawNode)}.`)
|
|
||||||
}
|
|
||||||
start = skipWhitespace(rawNode, endOfLabel + 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawNode[start] === '<') {
|
|
||||||
for (let index = start + 1; index < rawNode.length; index += 1) {
|
|
||||||
if (rawNode[index] === '\\') index += 1
|
|
||||||
else if (rawNode[index] === '>') return { start: start + 1, end: index }
|
|
||||||
}
|
|
||||||
throw new Error(`project-doc-site: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
let depth = 0
|
|
||||||
for (let index = start; index < rawNode.length; index += 1) {
|
|
||||||
const char = rawNode[index]
|
|
||||||
if (char === '\\') {
|
|
||||||
index += 1
|
|
||||||
} else if (char === '(') {
|
|
||||||
depth += 1
|
|
||||||
} else if (char === ')') {
|
|
||||||
if (depth === 0) return { start, end: index }
|
|
||||||
depth -= 1
|
|
||||||
} else if (/\s/.test(char ?? '') && depth === 0) {
|
|
||||||
return { start, end: index }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { start, end: rawNode.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
// `#fragment` suffixes pass through verbatim. Generated cordis-surface
|
// `#fragment` suffixes pass through verbatim. Generated cordis-surface
|
||||||
// headings carry explicit `<a id>` anchors with the GitHub slug, so those
|
// headings carry explicit `<a id>` anchors with the GitHub slug, so those
|
||||||
// fragments resolve on the published site too; hand-written headings rely on
|
// fragments resolve on the published site too; hand-written headings rely on
|
||||||
// VitePress's own slugger, which differs from GitHub's for punctuation-heavy
|
// VitePress's own slugger, which differs from GitHub's for punctuation-heavy
|
||||||
// text — hand-authored cross-page fragments should prefer plain-text headings
|
// text — hand-authored cross-page fragments should prefer plain-text headings
|
||||||
// or explicit anchors.
|
// or explicit anchors.
|
||||||
function splitTarget(url: string): { path: string; suffix: string } {
|
|
||||||
const boundary = url.search(/[?#]/)
|
|
||||||
if (boundary === -1) return { path: url, suffix: '' }
|
|
||||||
return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodePath(path: string): string {
|
function decodePath(path: string): string {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(path)
|
return decodeURIComponent(path)
|
||||||
|
|
@ -239,8 +157,8 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
|
||||||
const replacements: Replacement[] = []
|
const replacements: Replacement[] = []
|
||||||
|
|
||||||
const rewrite = (node: RewritableNode): void => {
|
const rewrite = (node: RewritableNode): void => {
|
||||||
if (isExternalOrSiteAbsolute(node.url)) return
|
if (isExternalOrAbsoluteMarkdownUrl(node.url)) return
|
||||||
const { path, suffix } = splitTarget(node.url)
|
const { path, suffix } = splitMarkdownUrlTarget(node.url)
|
||||||
if (path === '') return
|
if (path === '') return
|
||||||
const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
|
const { absPath, line } = resolveRepositoryTarget(sourceAbs, path, options.repoRoot)
|
||||||
const targetPath = repoPath(absPath, options.repoRoot)
|
const targetPath = repoPath(absPath, options.repoRoot)
|
||||||
|
|
@ -257,16 +175,10 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
|
||||||
? `${options.placeImage(absPath)}${suffix}`
|
? `${options.placeImage(absPath)}${suffix}`
|
||||||
: githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
|
: githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
|
||||||
|
|
||||||
const start = node.position?.start.offset
|
const destination = markdownDestination(source, node)
|
||||||
const end = node.position?.end.offset
|
|
||||||
if (start === undefined || end === undefined) {
|
|
||||||
throw new Error(`project-doc-site: link ${JSON.stringify(node.url)} has no source offsets.`)
|
|
||||||
}
|
|
||||||
const rawNode = source.slice(start, end)
|
|
||||||
const rawDestination = destinationRange(rawNode, node.type)
|
|
||||||
replacements.push({
|
replacements.push({
|
||||||
start: start + rawDestination.start,
|
start: destination.start,
|
||||||
end: start + rawDestination.end,
|
end: destination.end,
|
||||||
value: nextUrl,
|
value: nextUrl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
/** Regression coverage for locale-aware bilingual Markdown links. */
|
/** Regression coverage for locale-aware bilingual Markdown links. */
|
||||||
|
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
normalizeTranslationMarkdownLinks,
|
normalizeTranslationMarkdownLinks,
|
||||||
rewriteTranslationLinkLocales,
|
rewriteTranslationLinkLocales,
|
||||||
semanticTranslationLinkTarget,
|
|
||||||
translationLinkLocaleViolations,
|
translationLinkLocaleViolations,
|
||||||
} from './translation-links.ts'
|
} from './translation-links.ts'
|
||||||
|
import { removeFixtureSafely } from './test-fixture-cleanup.ts'
|
||||||
|
|
||||||
const roots: string[] = []
|
const roots: string[] = []
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
for (const root of roots.splice(0)) removeFixtureSafely(root)
|
||||||
})
|
})
|
||||||
|
|
||||||
function fixture(): string {
|
function fixture(): string {
|
||||||
|
|
@ -95,11 +95,11 @@ describe('translation link locale validation', () => {
|
||||||
'[章节](section/)\n',
|
'[章节](section/)\n',
|
||||||
{ repoRoot: root, sourcePath: 'docs/guide.zh.md' },
|
{ repoRoot: root, sourcePath: 'docs/guide.zh.md' },
|
||||||
)[0]).toMatchObject({ expectedUrl: 'section/index.zh.md' })
|
)[0]).toMatchObject({ expectedUrl: 'section/index.zh.md' })
|
||||||
expect(semanticTranslationLinkTarget(
|
expect(normalizeTranslationMarkdownLinks(
|
||||||
'section/',
|
'[Section](section/)\n',
|
||||||
{ repoRoot: root, sourcePath: 'docs/guide.md' },
|
{ repoRoot: root, sourcePath: 'docs/guide.md' },
|
||||||
)).toBe(semanticTranslationLinkTarget(
|
)).toBe(normalizeTranslationMarkdownLinks(
|
||||||
'section/index.zh.md',
|
'[Section](section/index.zh.md)\n',
|
||||||
{ repoRoot: root, sourcePath: 'docs/guide.zh.md' },
|
{ repoRoot: root, sourcePath: 'docs/guide.zh.md' },
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,14 @@
|
||||||
import { existsSync, statSync } from 'node:fs'
|
import { existsSync, statSync } from 'node:fs'
|
||||||
import { posix, resolve } from 'node:path'
|
import { posix, resolve } from 'node:path'
|
||||||
import type { Nodes } from 'mdast'
|
import type { Nodes } from 'mdast'
|
||||||
import { parseMarkdown, visitMarkdown } from './markdown.ts'
|
import {
|
||||||
|
isExternalOrAbsoluteMarkdownUrl,
|
||||||
|
markdownDestination,
|
||||||
|
parseMarkdown,
|
||||||
|
splitMarkdownUrlTarget,
|
||||||
|
visitMarkdown,
|
||||||
|
type MarkdownDestination,
|
||||||
|
} from './markdown.ts'
|
||||||
|
|
||||||
/** Repository and source document used to resolve one relative link. */
|
/** Repository and source document used to resolve one relative link. */
|
||||||
export interface TranslationLinkContext {
|
export interface TranslationLinkContext {
|
||||||
|
|
@ -50,32 +57,9 @@ interface Replacement {
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DestinationRange {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthoredDestination extends DestinationRange {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type LinkNode = Extract<Nodes, { type: 'link' | 'definition' }>
|
type LinkNode = Extract<Nodes, { type: 'link' | 'definition' }>
|
||||||
type ResolutionKind = 'exact' | 'extensionless' | 'directory-index'
|
type ResolutionKind = 'exact' | 'extensionless' | 'directory-index'
|
||||||
|
|
||||||
function isExternalOrAbsolute(url: string): boolean {
|
|
||||||
return url.startsWith('#')
|
|
||||||
|| url.startsWith('//')
|
|
||||||
|| url.startsWith('/')
|
|
||||||
|| /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Split a URL without normalizing its query or fragment suffix. */
|
|
||||||
export function splitTranslationLinkTarget(url: string): { path: string; suffix: string } {
|
|
||||||
const boundary = url.search(/[?#]/)
|
|
||||||
if (boundary === -1) return { path: url, suffix: '' }
|
|
||||||
return { path: url.slice(0, boundary), suffix: url.slice(boundary) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodePath(path: string): string {
|
function decodePath(path: string): string {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(path)
|
return decodeURIComponent(path)
|
||||||
|
|
@ -170,9 +154,9 @@ function resolveTranslationLink(
|
||||||
context: TranslationLinkContext,
|
context: TranslationLinkContext,
|
||||||
authoredUrl: string = url,
|
authoredUrl: string = url,
|
||||||
): ResolvedTranslationLink | undefined {
|
): ResolvedTranslationLink | undefined {
|
||||||
if (isExternalOrAbsolute(url)) return undefined
|
if (isExternalOrAbsoluteMarkdownUrl(url)) return undefined
|
||||||
const { path } = splitTranslationLinkTarget(url)
|
const { path } = splitMarkdownUrlTarget(url)
|
||||||
const authored = splitTranslationLinkTarget(authoredUrl)
|
const authored = splitMarkdownUrlTarget(authoredUrl)
|
||||||
if (path === '') return undefined
|
if (path === '') return undefined
|
||||||
const resolved = resolveRepositoryTarget(path, context)
|
const resolved = resolveRepositoryTarget(path, context)
|
||||||
if (resolved === undefined) return undefined
|
if (resolved === undefined) return undefined
|
||||||
|
|
@ -197,76 +181,7 @@ function hasExpectedLocale(resolved: ResolvedTranslationLink): boolean {
|
||||||
return !(resolved.locale === 'en' && resolved.kind === 'extensionless')
|
return !(resolved.locale === 'en' && resolved.kind === 'extensionless')
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipWhitespace(source: string, start: number): number {
|
function replacementFor(destination: MarkdownDestination, value: string): Replacement {
|
||||||
let index = start
|
|
||||||
while (/\s/.test(source[index] ?? '')) index += 1
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelEnd(source: string): number {
|
|
||||||
const first = source.indexOf('[')
|
|
||||||
if (first === -1) return -1
|
|
||||||
let depth = 0
|
|
||||||
for (let index = first; index < source.length; index += 1) {
|
|
||||||
const char = source[index]
|
|
||||||
if (char === '\\') index += 1
|
|
||||||
else if (char === '[') depth += 1
|
|
||||||
else if (char === ']') {
|
|
||||||
depth -= 1
|
|
||||||
if (depth === 0) return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
function destinationRange(rawNode: string, type: LinkNode['type']): DestinationRange {
|
|
||||||
const endOfLabel = labelEnd(rawNode)
|
|
||||||
if (endOfLabel === -1) throw new Error(`translation-links: cannot locate label end in ${JSON.stringify(rawNode)}`)
|
|
||||||
let start: number
|
|
||||||
if (type === 'definition') {
|
|
||||||
const colon = rawNode.indexOf(':', endOfLabel + 1)
|
|
||||||
if (colon === -1) throw new Error(`translation-links: cannot locate definition separator in ${JSON.stringify(rawNode)}`)
|
|
||||||
start = skipWhitespace(rawNode, colon + 1)
|
|
||||||
} else {
|
|
||||||
if (rawNode[endOfLabel + 1] !== '(') {
|
|
||||||
throw new Error(`translation-links: cannot locate inline destination in ${JSON.stringify(rawNode)}`)
|
|
||||||
}
|
|
||||||
start = skipWhitespace(rawNode, endOfLabel + 2)
|
|
||||||
}
|
|
||||||
if (rawNode[start] === '<') {
|
|
||||||
for (let index = start + 1; index < rawNode.length; index += 1) {
|
|
||||||
if (rawNode[index] === '\\') index += 1
|
|
||||||
else if (rawNode[index] === '>') return { start: start + 1, end: index }
|
|
||||||
}
|
|
||||||
throw new Error(`translation-links: cannot locate angle-bracket destination end in ${JSON.stringify(rawNode)}`)
|
|
||||||
}
|
|
||||||
let depth = 0
|
|
||||||
for (let index = start; index < rawNode.length; index += 1) {
|
|
||||||
const char = rawNode[index]
|
|
||||||
if (char === '\\') index += 1
|
|
||||||
else if (char === '(') depth += 1
|
|
||||||
else if (char === ')') {
|
|
||||||
if (depth === 0) return { start, end: index }
|
|
||||||
depth -= 1
|
|
||||||
} else if (/\s/.test(char ?? '') && depth === 0) {
|
|
||||||
return { start, end: index }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { start, end: rawNode.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
function authoredDestination(markdown: string, node: LinkNode): AuthoredDestination {
|
|
||||||
const start = node.position?.start.offset
|
|
||||||
const end = node.position?.end.offset
|
|
||||||
if (start === undefined || end === undefined) {
|
|
||||||
throw new Error(`translation-links: link ${JSON.stringify(node.url)} has no source offsets`)
|
|
||||||
}
|
|
||||||
const range = destinationRange(markdown.slice(start, end), node.type)
|
|
||||||
const absolute = { start: start + range.start, end: start + range.end }
|
|
||||||
return { ...absolute, url: markdown.slice(absolute.start, absolute.end) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function replacementFor(destination: AuthoredDestination, value: string): Replacement {
|
|
||||||
return { start: destination.start, end: destination.end, value }
|
return { start: destination.start, end: destination.end, value }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,23 +206,34 @@ function visitDocumentLinkNodes(markdown: string, visitor: (node: LinkNode) => v
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visitResolvedDocumentLinks(
|
||||||
|
markdown: string,
|
||||||
|
context: TranslationLinkContext,
|
||||||
|
skipTargets: readonly string[],
|
||||||
|
visitor: (node: LinkNode, destination: MarkdownDestination, resolved: ResolvedTranslationLink) => void,
|
||||||
|
): void {
|
||||||
|
const skipped = new Set(skipTargets)
|
||||||
|
visitDocumentLinkNodes(markdown, (node) => {
|
||||||
|
if (skipped.has(node.url)) return
|
||||||
|
const destination = markdownDestination(markdown, node)
|
||||||
|
const resolved = resolveTranslationLink(node.url, context, destination.url)
|
||||||
|
if (resolved !== undefined) visitor(node, destination, resolved)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Return one violation per wrong-locale link or link definition. */
|
/** Return one violation per wrong-locale link or link definition. */
|
||||||
export function translationLinkLocaleViolations(
|
export function translationLinkLocaleViolations(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
context: TranslationLinkContext,
|
context: TranslationLinkContext,
|
||||||
skipTargets: readonly string[] = [],
|
skipTargets: readonly string[] = [],
|
||||||
): TranslationLinkLocaleViolation[] {
|
): TranslationLinkLocaleViolation[] {
|
||||||
const skipped = new Set(skipTargets)
|
|
||||||
const violations: TranslationLinkLocaleViolation[] = []
|
const violations: TranslationLinkLocaleViolation[] = []
|
||||||
visitDocumentLinkNodes(markdown, (node) => {
|
visitResolvedDocumentLinks(markdown, context, skipTargets, (node, destination, resolved) => {
|
||||||
if (skipped.has(node.url)) return
|
if (hasExpectedLocale(resolved)) return
|
||||||
const authored = authoredDestination(markdown, node)
|
|
||||||
const resolved = resolveTranslationLink(node.url, context, authored.url)
|
|
||||||
if (resolved === undefined || hasExpectedLocale(resolved)) return
|
|
||||||
violations.push({
|
violations.push({
|
||||||
sourcePath: context.sourcePath,
|
sourcePath: context.sourcePath,
|
||||||
line: node.position?.start.line ?? 0,
|
line: node.position?.start.line ?? 0,
|
||||||
url: authored.url,
|
url: destination.url,
|
||||||
expectedUrl: resolved.expectedUrl,
|
expectedUrl: resolved.expectedUrl,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -320,14 +246,10 @@ export function rewriteTranslationLinkLocales(
|
||||||
context: TranslationLinkContext,
|
context: TranslationLinkContext,
|
||||||
skipTargets: readonly string[] = [],
|
skipTargets: readonly string[] = [],
|
||||||
): TranslationLinkRewriteResult {
|
): TranslationLinkRewriteResult {
|
||||||
const skipped = new Set(skipTargets)
|
|
||||||
const replacements: Replacement[] = []
|
const replacements: Replacement[] = []
|
||||||
visitDocumentLinkNodes(markdown, (node) => {
|
visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => {
|
||||||
if (skipped.has(node.url)) return
|
if (hasExpectedLocale(resolved)) return
|
||||||
const authored = authoredDestination(markdown, node)
|
replacements.push(replacementFor(destination, resolved.expectedUrl))
|
||||||
const resolved = resolveTranslationLink(node.url, context, authored.url)
|
|
||||||
if (resolved === undefined || hasExpectedLocale(resolved)) return
|
|
||||||
replacements.push(replacementFor(authored, resolved.expectedUrl))
|
|
||||||
})
|
})
|
||||||
return { content: applyReplacements(markdown, replacements), rewritten: replacements.length }
|
return { content: applyReplacements(markdown, replacements), rewritten: replacements.length }
|
||||||
}
|
}
|
||||||
|
|
@ -338,38 +260,25 @@ export function normalizeTranslationMarkdownLinks(
|
||||||
context: TranslationLinkContext,
|
context: TranslationLinkContext,
|
||||||
skipTargets: readonly string[] = [],
|
skipTargets: readonly string[] = [],
|
||||||
): string {
|
): string {
|
||||||
const skipped = new Set(skipTargets)
|
|
||||||
const replacements: Replacement[] = []
|
const replacements: Replacement[] = []
|
||||||
visitDocumentLinkNodes(markdown, (node) => {
|
visitResolvedDocumentLinks(markdown, context, skipTargets, (_node, destination, resolved) => {
|
||||||
if (skipped.has(node.url)) return
|
|
||||||
const authored = authoredDestination(markdown, node)
|
|
||||||
const resolved = resolveTranslationLink(node.url, context, authored.url)
|
|
||||||
if (resolved === undefined) return
|
|
||||||
replacements.push(replacementFor(
|
replacements.push(replacementFor(
|
||||||
authored,
|
destination,
|
||||||
`dsh-translation-target:${resolved.pair.source}${resolved.suffix}`,
|
`dsh-translation-target:${resolved.pair.source}${resolved.suffix}`,
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
return applyReplacements(markdown, replacements)
|
return applyReplacements(markdown, replacements)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Semantic target used by the pair structure signature. */
|
|
||||||
export function semanticTranslationLinkTarget(url: string, context: TranslationLinkContext): string {
|
|
||||||
const resolved = resolveTranslationLink(url, context)
|
|
||||||
return resolved === undefined
|
|
||||||
? url
|
|
||||||
: `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Semantic target of one authored inline link or referenced definition. */
|
/** Semantic target of one authored inline link or referenced definition. */
|
||||||
export function semanticTranslationLinkNodeTarget(
|
export function semanticTranslationLinkNodeTarget(
|
||||||
node: LinkNode,
|
node: LinkNode,
|
||||||
markdown: string,
|
markdown: string,
|
||||||
context: TranslationLinkContext,
|
context: TranslationLinkContext,
|
||||||
): string {
|
): string {
|
||||||
const authored = authoredDestination(markdown, node)
|
const destination = markdownDestination(markdown, node)
|
||||||
const resolved = resolveTranslationLink(node.url, context, authored.url)
|
const resolved = resolveTranslationLink(node.url, context, destination.url)
|
||||||
return resolved === undefined
|
return resolved === undefined
|
||||||
? authored.url
|
? destination.url
|
||||||
: `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`
|
: `dsh-translation-target:${resolved.pair.source}${resolved.suffix}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue