@master/internal-site 0.1.3 → 0.2.1
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 +37 -0
- package/dist/astro.mjs +92 -41
- package/dist/config.d.mts +6 -0
- package/dist/localization-LfIbIysE.mjs +356 -0
- package/dist/localization.d.mts +69 -0
- package/dist/localization.mjs +1 -0
- package/package.json +12 -9
- package/src/astro/content-pages.ts +30 -11
- package/src/astro/index.ts +9 -5
- package/src/astro/localized-mdx.ts +45 -0
- package/src/astro/routes.template.ts +3 -0
- package/src/astro/site-context.ts +10 -2
- package/src/benchmarks/BenchmarkChart.astro +4 -1
- package/src/benchmarks/BenchmarkMetricTable.astro +1 -1
- package/src/benchmarks/BenchmarkStackedBars.astro +2 -2
- package/src/benchmarks/BenchmarkStatus.astro +3 -1
- package/src/benchmarks/localization.ts +22 -0
- package/src/blog/article/BlogArticleEnd.astro +6 -9
- package/src/blog/article/BlogArticleMasthead.astro +5 -8
- package/src/config.ts +3 -0
- package/src/css/foundation-data/specimen-content.ts +2 -2
- package/src/css/markdown.ts +52 -11
- package/src/demo/Demo.svelte +6 -4
- package/src/demo/DemoAsset.astro +5 -3
- package/src/demo/DemoAxes.svelte +4 -2
- package/src/demo/DemoCatalog.astro +8 -6
- package/src/demo/DemoContainer.svelte +5 -3
- package/src/demo/DemoControls.svelte +3 -1
- package/src/demo/DemoCopyButton.svelte +6 -4
- package/src/demo/DemoCopyGroup.svelte +4 -2
- package/src/demo/DemoExample.astro +3 -1
- package/src/demo/DemoFeatureSupport.svelte +3 -1
- package/src/demo/DemoGeneratedCSS.svelte +3 -1
- package/src/demo/DemoIndex.astro +5 -3
- package/src/demo/DemoLegend.svelte +3 -1
- package/src/demo/DemoMeasure.svelte +4 -2
- package/src/demo/DemoMedia.svelte +3 -1
- package/src/demo/DemoMotion.svelte +5 -3
- package/src/demo/DemoPaletteGroups.svelte +4 -2
- package/src/demo/DemoStyleComparison.astro +5 -3
- package/src/demo/DemoThemeComparison.astro +4 -2
- package/src/demo/DemoTokenTable.astro +4 -2
- package/src/demo/DemoViewTransition.astro +3 -1
- package/src/demo/DemoViewport.svelte +13 -11
- package/src/demo/DemoWaterfall.astro +3 -1
- package/src/demo/ProjectStyleExample.astro +4 -1
- package/src/demo/foundations/FoundationDialog.astro +5 -2
- package/src/demo/foundations/FoundationTokensView.astro +16 -5
- package/src/demo/foundations/Palette.svelte +9 -7
- package/src/demo/foundations/dialog-copy.d.ts +1 -0
- package/src/demo/foundations/dialog-copy.js +5 -0
- package/src/docs-shell/common/i18n.config.d.ts +1 -1
- package/src/docs-shell/common/i18n.config.js +4 -12
- package/src/docs-shell/common/locales.d.ts +8 -0
- package/src/docs-shell/common/locales.js +18 -0
- package/src/docs-shell/common/terminology.json +32 -0
- package/src/docs-shell/common/translations.d.ts +3 -0
- package/src/docs-shell/common/translations.js +294 -0
- package/src/docs-shell/components/ArticleHeader.astro +3 -3
- package/src/docs-shell/components/CodeCopyButton.astro +3 -1
- package/src/docs-shell/components/CodeTabBar.astro +5 -3
- package/src/docs-shell/components/DocHeader.svelte +3 -4
- package/src/docs-shell/components/DocHeading.astro +3 -1
- package/src/docs-shell/components/DocMenuButton.svelte +4 -5
- package/src/docs-shell/components/DocSidebar.svelte +1 -2
- package/src/docs-shell/components/DocumentCodeTable.astro +3 -1
- package/src/docs-shell/components/DocumentIllustration.astro +3 -1
- package/src/docs-shell/components/DocumentKeyList.astro +5 -3
- package/src/docs-shell/components/DocumentNamespaceTable.astro +4 -2
- package/src/docs-shell/components/DocumentOptions.astro +3 -1
- package/src/docs-shell/components/DocumentationIndex.svelte +6 -7
- package/src/docs-shell/components/GeneratedCSS.astro +3 -1
- package/src/docs-shell/components/Link.svelte +3 -2
- package/src/docs-shell/components/NotFound.astro +3 -4
- package/src/docs-shell/components/PageNavs.astro +2 -3
- package/src/docs-shell/components/SearchDialog.svelte +13 -16
- package/src/docs-shell/components/TimeAgo.svelte +6 -6
- package/src/docs-shell/contexts/search.ts +1 -1
- package/src/docs-shell/factories/Page.astro +10 -5
- package/src/docs-shell/factories/create-page.ts +33 -6
- package/src/docs-shell/layouts/BlogArticle.astro +3 -11
- package/src/docs-shell/remark/slug-and-toc.js +5 -7
- package/src/docs-shell/scripts/code.ts +5 -2
- package/src/docs-shell/site.ts +9 -1
- package/src/docs-shell/utils/document-copy.d.ts +2 -0
- package/src/docs-shell/utils/document-copy.js +18 -0
- package/src/docs-shell/utils/generate-metadata.ts +2 -0
- package/src/docs-shell/utils/i18n-pathname.ts +13 -2
- package/src/docs-shell/utils/i18n.ts +4 -1
- package/src/docs-shell/utils/localized-metadata.ts +22 -0
- package/src/docs-shell/utils/markdown-links.ts +61 -0
- package/src/docs-shell/utils/markdown-tree.ts +19 -6
- package/src/docs-shell/utils/resolve-heading.d.ts +1 -0
- package/src/docs-shell/utils/resolve-heading.js +21 -0
- package/src/docs-shell/utils/search-pages.ts +75 -22
- package/src/home/hero.css +5 -0
- package/src/layouts/Document.astro +1 -1
- package/src/scripts/localization.ts +239 -0
- package/src/scripts/mdx-sample.ts +12 -0
- package/src/scripts/static-social.ts +17 -3
- package/src/scripts/style-delivery.mjs +14 -4
- package/src/sections/guide.astro +3 -1
- package/src/styles/docs-shell/prose.css +1 -1
- package/src/virtual.d.ts +5 -1
- package/src/blog/article/language.ts +0 -29
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Prose can vary by locale; publication identity and technical configuration stay with the source. */
|
|
2
|
+
export function localizedMetadata(metadata: Record<string, any>, frontmatter: Record<string, any>, locale: string) {
|
|
3
|
+
const localized: Record<string, any> = { ...metadata, locale }
|
|
4
|
+
for (const key of ['description', 'subtitle']) if (typeof frontmatter[key] === 'string') localized[key] = frontmatter[key]
|
|
5
|
+
if (typeof frontmatter.title === 'string') {
|
|
6
|
+
localized.title = metadata.title && typeof metadata.title === 'object'
|
|
7
|
+
? { ...metadata.title, ...('absolute' in metadata.title ? { absolute: frontmatter.title } : { default: frontmatter.title }) }
|
|
8
|
+
: frontmatter.title
|
|
9
|
+
}
|
|
10
|
+
if (metadata.cover && typeof frontmatter.cover?.alt === 'string') localized.cover = { ...metadata.cover, alt: frontmatter.cover.alt }
|
|
11
|
+
for (const channel of ['openGraph', 'twitter']) {
|
|
12
|
+
const authored = frontmatter[channel]
|
|
13
|
+
if (metadata[channel] || authored) {
|
|
14
|
+
const prose = Object.fromEntries(['title', 'description'].flatMap(key => {
|
|
15
|
+
const text = authored?.[key] ?? (locale !== 'en' ? frontmatter[key] : undefined)
|
|
16
|
+
return typeof text === 'string' ? [[key, text]] : []
|
|
17
|
+
}))
|
|
18
|
+
localized[channel] = { ...metadata[channel], ...prose }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return localized
|
|
22
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { markdownTree } from './markdown-tree'
|
|
2
|
+
|
|
3
|
+
interface LinkNode {
|
|
4
|
+
type: string
|
|
5
|
+
url?: string
|
|
6
|
+
children?: LinkNode[]
|
|
7
|
+
position?: { start: { offset: number }; end: { offset: number } }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function destination(source: string, node: LinkNode): { start: number; end: number; angled: boolean } {
|
|
11
|
+
const { start, end } = node.position!
|
|
12
|
+
let at = start.offset
|
|
13
|
+
if (node.type === 'definition') {
|
|
14
|
+
for (; at < end.offset; at++) {
|
|
15
|
+
if (source[at] === '\\') { at++; continue }
|
|
16
|
+
if (source[at] === ']' && source[at + 1] === ':') { at += 2; break }
|
|
17
|
+
}
|
|
18
|
+
} else if (source[at] === '[') {
|
|
19
|
+
// Child positions include inline-code and nested formatting in the label.
|
|
20
|
+
at = node.children?.at(-1)?.position?.end.offset ?? at + 1
|
|
21
|
+
at = source.indexOf('](', at) + 2
|
|
22
|
+
if (at < 2) throw new Error(`Missing Markdown link destination: ${node.url}`)
|
|
23
|
+
} else {
|
|
24
|
+
// GFM bare links and CommonMark angle autolinks have no bracketed label.
|
|
25
|
+
const angled = source[at] === '<'
|
|
26
|
+
return { start: at + Number(angled), end: end.offset - Number(angled), angled }
|
|
27
|
+
}
|
|
28
|
+
while (/\s/.test(source[at] ?? '') && at < end.offset) at++
|
|
29
|
+
const angled = source[at] === '<'
|
|
30
|
+
const begin = at + Number(angled)
|
|
31
|
+
at = begin
|
|
32
|
+
let depth = 0
|
|
33
|
+
for (; at < end.offset; at++) {
|
|
34
|
+
const character = source[at]
|
|
35
|
+
if (character === '\\') { at++; continue }
|
|
36
|
+
if (angled ? character === '>' : /\s/.test(character) || (character === ')' && depth === 0)) break
|
|
37
|
+
if (!angled && character === '(') depth++
|
|
38
|
+
else if (!angled && character === ')') depth--
|
|
39
|
+
}
|
|
40
|
+
return { start: begin, end: at, angled }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Edit actual Markdown link/definition URLs without reserializing prose or code. */
|
|
44
|
+
export function transformMarkdownLinks(markdown: string, mapper: (url: string) => string): string {
|
|
45
|
+
const edits: { start: number; end: number; value: string }[] = []
|
|
46
|
+
function visit(node: LinkNode) {
|
|
47
|
+
if ((node.type === 'link' || node.type === 'definition') && node.position && typeof node.url === 'string') {
|
|
48
|
+
const mapped = mapper(node.url)
|
|
49
|
+
if (mapped !== node.url) {
|
|
50
|
+
const range = destination(markdown, node)
|
|
51
|
+
const escaped = mapped.replace(/([\\<>])/g, '\\$1')
|
|
52
|
+
const value = range.angled ? escaped : /\s/.test(mapped) ? `<${escaped}>` : escaped.replace(/([()])/g, '\\$1')
|
|
53
|
+
edits.push({ ...range, value })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
node.children?.forEach(visit)
|
|
57
|
+
}
|
|
58
|
+
visit(markdownTree(markdown) as LinkNode)
|
|
59
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) markdown = markdown.slice(0, edit.start) + edit.value + markdown.slice(edit.end)
|
|
60
|
+
return markdown
|
|
61
|
+
}
|
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
import { remark } from 'remark'
|
|
2
2
|
import gfm from 'remark-gfm'
|
|
3
|
-
import
|
|
4
|
-
import resolveHeading from './resolve-heading'
|
|
3
|
+
import resolveHeading, { createHeadingResolver } from './resolve-heading'
|
|
5
4
|
|
|
6
5
|
const parser = remark().use(gfm)
|
|
7
|
-
export
|
|
6
|
+
export function markdownTree(source: string) {
|
|
7
|
+
const tree = parser.parse(source)
|
|
8
|
+
const resolveHeading = createHeadingResolver()
|
|
9
|
+
function visit(node: any) {
|
|
10
|
+
if (node.type === 'heading' && node.depth >= 2 && node.depth <= 6) {
|
|
11
|
+
const title = node.children.filter((child: any) => child.type === 'text' || child.type === 'inlineCode').map((child: any) => child.value).join(' ').trim()
|
|
12
|
+
const resolved = resolveHeading(title, node.depth)
|
|
13
|
+
node.data ??= {}
|
|
14
|
+
node.data.id = resolved.id
|
|
15
|
+
node.data.hProperties = { ...node.data.hProperties, id: resolved.id }
|
|
16
|
+
}
|
|
17
|
+
node.children?.forEach(visit)
|
|
18
|
+
}
|
|
19
|
+
visit(tree)
|
|
20
|
+
return tree
|
|
21
|
+
}
|
|
8
22
|
|
|
9
23
|
export function markdownHeadings(source: string) {
|
|
10
|
-
const slugger = new Slugger()
|
|
11
24
|
const headings: { id: string; title: string; depth: number }[] = []
|
|
12
25
|
function visit(node: any) {
|
|
13
26
|
if (node.type === 'heading' && (node.depth === 2 || node.depth === 3)) {
|
|
14
27
|
const title = node.children.filter((child: any) => child.type === 'text' || child.type === 'inlineCode').map((child: any) => child.value).join(' ').trim()
|
|
15
|
-
const resolved = resolveHeading(title,
|
|
16
|
-
headings.push({ id:
|
|
28
|
+
const resolved = resolveHeading(title, node.data.hProperties.id)
|
|
29
|
+
headings.push({ id: node.data.hProperties.id, title: resolved.title, depth: node.depth })
|
|
17
30
|
}
|
|
18
31
|
node.children?.forEach(visit)
|
|
19
32
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import Slugger from 'github-slugger'
|
|
2
|
+
|
|
1
3
|
export default function resolveHeading(title, id) {
|
|
2
4
|
const explicit = typeof title === 'string' && title.match(/\s+\{#([\w-]+)\}$/)
|
|
3
5
|
if (explicit) {
|
|
@@ -17,3 +19,22 @@ export default function resolveHeading(title, id) {
|
|
|
17
19
|
}
|
|
18
20
|
return { title, id }
|
|
19
21
|
}
|
|
22
|
+
|
|
23
|
+
/** Section counters stay stable when a detail heading is inserted between them. */
|
|
24
|
+
export function createHeadingResolver() {
|
|
25
|
+
const sections = new Slugger()
|
|
26
|
+
const details = new Slugger()
|
|
27
|
+
const assigned = new Set()
|
|
28
|
+
return (title, depth) => {
|
|
29
|
+
const explicit = /\s+\{#[\w-]+\}$/.test(title)
|
|
30
|
+
let heading
|
|
31
|
+
if (explicit) heading = resolveHeading(title)
|
|
32
|
+
else {
|
|
33
|
+
const slugs = depth === 2 || depth === 3 ? sections : details
|
|
34
|
+
do { heading = resolveHeading(title, slugs.slug(title)) }
|
|
35
|
+
while (heading.id && assigned.has(heading.id))
|
|
36
|
+
}
|
|
37
|
+
if (heading.id) assigned.add(heading.id)
|
|
38
|
+
return { ...heading, explicit }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import FastGlob from 'fast-glob'
|
|
2
|
-
import Slugger from 'github-slugger'
|
|
3
2
|
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
4
3
|
import { dirname, join, resolve } from 'node:path'
|
|
5
4
|
import { pathToFileURL } from 'node:url'
|
|
6
|
-
import {
|
|
5
|
+
import { remark } from 'remark'
|
|
6
|
+
import gfm from 'remark-gfm'
|
|
7
7
|
import { mdxjsEsm } from 'micromark-extension-mdxjs-esm'
|
|
8
|
+
import { mdxJsx } from 'micromark-extension-mdx-jsx'
|
|
9
|
+
import { mdxJsxFromMarkdown } from 'mdast-util-mdx-jsx'
|
|
8
10
|
import { mdxjsEsmFromMarkdown } from 'mdast-util-mdxjs-esm'
|
|
9
11
|
import * as acorn from 'acorn'
|
|
10
|
-
import
|
|
12
|
+
import { createHeadingResolver } from './resolve-heading.js'
|
|
11
13
|
import { writeIfChangedSync } from '../../scripts/write-if-changed'
|
|
12
14
|
import { localizePathname, type LocalePrefixMode } from './i18n-pathname.js'
|
|
15
|
+
import { parseFrontmatter } from '@astrojs/markdown-remark'
|
|
16
|
+
import { localizedMetadata } from './localized-metadata'
|
|
17
|
+
|
|
18
|
+
// Parse complete MDX boundaries so imports in example props are not mistaken for article ESM.
|
|
19
|
+
const markdownParser = remark().use(gfm)
|
|
20
|
+
const searchParser = remark().use(gfm).use(function mdxSearchSyntax() {
|
|
21
|
+
const data = this.data()
|
|
22
|
+
;(data.micromarkExtensions ??= []).push({ disable: { null: ['htmlFlow', 'htmlText', 'codeIndented'] } }, mdxjsEsm({ acorn, addResult: true }), mdxJsx({ acorn, addResult: true }))
|
|
23
|
+
// Consumers may resolve another mdast type version through remark-parse.
|
|
24
|
+
const extensions = [mdxjsEsmFromMarkdown(), mdxJsxFromMarkdown()]
|
|
25
|
+
;(data.fromMarkdownExtensions ??= []).push(...extensions as unknown as NonNullable<typeof data.fromMarkdownExtensions>)
|
|
26
|
+
})
|
|
13
27
|
|
|
14
28
|
const DEFAULT_METADATA_GLOB = './content/**/*metadata.ts'
|
|
15
29
|
|
|
@@ -63,6 +77,7 @@ interface MdxNode {
|
|
|
63
77
|
depth?: number
|
|
64
78
|
type: string
|
|
65
79
|
value?: string
|
|
80
|
+
explicitId?: string
|
|
66
81
|
}
|
|
67
82
|
|
|
68
83
|
export async function generateSearchPages({
|
|
@@ -82,6 +97,12 @@ export async function generateSearchPages({
|
|
|
82
97
|
const translations = loadDictionaryTranslations(dictionariesDir, locale)
|
|
83
98
|
const translate = createDictionaryTranslate(translations)
|
|
84
99
|
const pages = entries
|
|
100
|
+
.filter(entry => {
|
|
101
|
+
if (entry.metadata.draft) return false
|
|
102
|
+
const content = locale === defaultLocale ? entry.content : entry.localizedContent?.[locale]
|
|
103
|
+
if (locale !== defaultLocale && entry.content && !content) return false
|
|
104
|
+
return parseFrontmatter(content ?? '').frontmatter.draft !== true
|
|
105
|
+
})
|
|
85
106
|
.map((entry) => createSearchPage({ defaultLocale, entry, locale, translate }))
|
|
86
107
|
.sort((a, b) => a.url.localeCompare(b.url))
|
|
87
108
|
result[locale] = pages
|
|
@@ -102,7 +123,7 @@ export async function loadSearchPageEntries({
|
|
|
102
123
|
.filter((metadataPath) => (existsSync(resolve(cwd, dirname(metadataPath), 'page.astro')) || existsSync(resolve(cwd, dirname(metadataPath), 'content.mdx'))))
|
|
103
124
|
.sort()
|
|
104
125
|
|
|
105
|
-
return await Promise.all(metadataPaths.map(async (metadataPath) => {
|
|
126
|
+
return (await Promise.all(metadataPaths.map(async (metadataPath) => {
|
|
106
127
|
const metadataModule = await import(pathToFileURL(resolve(cwd, metadataPath)).href)
|
|
107
128
|
const contentPath = resolve(cwd, dirname(metadataPath), 'content.mdx')
|
|
108
129
|
const contentDir = resolve(cwd, dirname(metadataPath))
|
|
@@ -112,7 +133,7 @@ export async function loadSearchPageEntries({
|
|
|
112
133
|
metadata: metadataModule.default,
|
|
113
134
|
metadataPath
|
|
114
135
|
}
|
|
115
|
-
}))
|
|
136
|
+
}))).filter(entry => !entry.metadata.draft && !parseFrontmatter(entry.content ?? '').frontmatter.draft)
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
export function createSearchPage({
|
|
@@ -121,11 +142,15 @@ export function createSearchPage({
|
|
|
121
142
|
locale,
|
|
122
143
|
translate = (text) => text
|
|
123
144
|
}: CreateSearchPageOptions): SearchPage {
|
|
124
|
-
const
|
|
145
|
+
const source = locale === defaultLocale ? entry.content : entry.localizedContent?.[locale]
|
|
146
|
+
if (locale !== defaultLocale && entry.content && !source) throw new Error(`Missing search translation: ${entry.metadata.pathname} (${locale})`)
|
|
147
|
+
const parsed = source === undefined ? undefined : parseFrontmatter(source)
|
|
148
|
+
if (parsed?.frontmatter.draft) throw new Error(`Draft translation cannot be indexed: ${entry.metadata.pathname} (${locale})`)
|
|
149
|
+
const metadata = localizedMetadata(entry.metadata, parsed?.frontmatter ?? {}, locale)
|
|
125
150
|
const title = translate(resolveMetadataString(metadata.title))
|
|
126
151
|
const description = translate(resolveMetadataString(metadata.description))
|
|
127
152
|
const category = translate(resolveMetadataString(metadata.category))
|
|
128
|
-
const content =
|
|
153
|
+
const content = parsed?.content
|
|
129
154
|
const page: SearchPage = {
|
|
130
155
|
category,
|
|
131
156
|
description,
|
|
@@ -170,26 +195,50 @@ export function createLocalizedPathname(pathname: string, locale: string, defaul
|
|
|
170
195
|
}
|
|
171
196
|
|
|
172
197
|
export function extractSearchNodesFromMdx(source: string): SearchNode[] {
|
|
173
|
-
const tree =
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
198
|
+
const tree = searchParser.parse(parseFrontmatter(source).content) as MdxNode
|
|
199
|
+
return searchNodesFromTree(tree)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Portable exports are Markdown and may include native HTML comments or type notation. */
|
|
203
|
+
export function extractSearchNodesFromMarkdown(source: string): SearchNode[] {
|
|
204
|
+
const tree = markdownParser.parse(parseFrontmatter(source).content) as MdxNode
|
|
205
|
+
function resolvePortableAnchors(parent: MdxNode) {
|
|
206
|
+
let pendingId: string | undefined
|
|
207
|
+
for (const node of parent.children ?? []) {
|
|
208
|
+
const markup = node.type === 'html' ? node.value : node.type === 'paragraph'
|
|
209
|
+
&& node.children?.every(child => child.type === 'html' || (child.type === 'text' && !child.value?.trim()))
|
|
210
|
+
? node.children.map(child => child.value ?? '').join('') : undefined
|
|
211
|
+
const anchors = [...(markup ?? '').matchAll(/<a\s+id="([^"]+)"\s*><\/a>/g)]
|
|
212
|
+
if (anchors.length && !markup!.replace(/<a\s+id="([^"]+)"\s*><\/a>/g, '').trim()) {
|
|
213
|
+
pendingId = anchors.at(-1)![1]
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
if (node.type === 'heading' && pendingId) node.explicitId = pendingId
|
|
217
|
+
pendingId = undefined
|
|
218
|
+
if (node.children) resolvePortableAnchors(node)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
resolvePortableAnchors(tree)
|
|
222
|
+
return searchNodesFromTree(tree)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function searchNodesFromTree(tree: MdxNode): SearchNode[] {
|
|
177
226
|
const nodes: SearchNode[] = []
|
|
178
|
-
const
|
|
179
|
-
appendSearchNodes(tree.children ?? [], nodes,
|
|
227
|
+
const resolveHeading = createHeadingResolver()
|
|
228
|
+
appendSearchNodes(tree.children ?? [], nodes, resolveHeading)
|
|
180
229
|
return nodes
|
|
181
230
|
}
|
|
182
231
|
|
|
183
|
-
function appendSearchNodes(children: MdxNode[], nodes: SearchNode[],
|
|
232
|
+
function appendSearchNodes(children: MdxNode[], nodes: SearchNode[], resolveHeading: ReturnType<typeof createHeadingResolver>) {
|
|
184
233
|
for (const child of children) {
|
|
185
|
-
appendSearchNode(child, nodes,
|
|
234
|
+
appendSearchNode(child, nodes, resolveHeading)
|
|
186
235
|
}
|
|
187
236
|
}
|
|
188
237
|
|
|
189
|
-
function appendSearchNode(node: MdxNode, nodes: SearchNode[],
|
|
238
|
+
function appendSearchNode(node: MdxNode, nodes: SearchNode[], resolveHeading: ReturnType<typeof createHeadingResolver>) {
|
|
190
239
|
switch (node.type) {
|
|
191
240
|
case 'heading':
|
|
192
|
-
appendHeadingSearchNode(node, nodes,
|
|
241
|
+
appendHeadingSearchNode(node, nodes, resolveHeading)
|
|
193
242
|
return
|
|
194
243
|
case 'paragraph':
|
|
195
244
|
appendTextSearchNode(nodes, collectText(node), 'p')
|
|
@@ -202,8 +251,11 @@ function appendSearchNode(node: MdxNode, nodes: SearchNode[], slugger: Slugger)
|
|
|
202
251
|
appendTextSearchNode(nodes, collectText(child), 'li')
|
|
203
252
|
}
|
|
204
253
|
return
|
|
254
|
+
case 'table':
|
|
255
|
+
for (const row of node.children ?? []) appendTextSearchNode(nodes, (row.children ?? []).map(collectText).join(' | '), 'tr')
|
|
256
|
+
return
|
|
205
257
|
case 'blockquote':
|
|
206
|
-
appendSearchNodes(node.children ?? [], nodes,
|
|
258
|
+
appendSearchNodes(node.children ?? [], nodes, resolveHeading)
|
|
207
259
|
return
|
|
208
260
|
case 'mdxjsEsm':
|
|
209
261
|
case 'html':
|
|
@@ -212,18 +264,19 @@ function appendSearchNode(node: MdxNode, nodes: SearchNode[], slugger: Slugger)
|
|
|
212
264
|
return
|
|
213
265
|
default:
|
|
214
266
|
if (node.children?.length) {
|
|
215
|
-
appendSearchNodes(node.children, nodes,
|
|
267
|
+
appendSearchNodes(node.children, nodes, resolveHeading)
|
|
216
268
|
}
|
|
217
269
|
}
|
|
218
270
|
}
|
|
219
271
|
|
|
220
|
-
function appendHeadingSearchNode(node: MdxNode, nodes: SearchNode[],
|
|
272
|
+
function appendHeadingSearchNode(node: MdxNode, nodes: SearchNode[], resolveHeading: ReturnType<typeof createHeadingResolver>) {
|
|
221
273
|
const text = normalizeSearchText((node.children ?? []).filter(child => child.type === 'text' || child.type === 'inlineCode').map(child => child.value).join(' '))
|
|
222
274
|
if (!text) return
|
|
223
275
|
|
|
224
276
|
const tag = node.depth ? `h${node.depth}` : 'h'
|
|
225
|
-
if (node.depth
|
|
226
|
-
const
|
|
277
|
+
if (node.depth && node.depth >= 2 && node.depth <= 6) {
|
|
278
|
+
const title = node.explicitId && !/\{#[\w-]+\}/.test(text) ? `${text} {#${node.explicitId}}` : text
|
|
279
|
+
const resolved = resolveHeading(title, node.depth)
|
|
227
280
|
appendTextSearchNode(nodes, resolved.title, tag, resolved.id)
|
|
228
281
|
return
|
|
229
282
|
}
|
package/src/home/hero.css
CHANGED
|
@@ -238,6 +238,11 @@
|
|
|
238
238
|
color: var(--home-text);
|
|
239
239
|
text-wrap: balance;
|
|
240
240
|
overflow-wrap: anywhere;
|
|
241
|
+
|
|
242
|
+
/* Keep Japanese phrases and Korean words intact; very narrow enlarged text can still wrap in an emergency. */
|
|
243
|
+
&:is(:lang(ja), :lang(ko)) {
|
|
244
|
+
word-break: keep-all;
|
|
245
|
+
}
|
|
241
246
|
}
|
|
242
247
|
|
|
243
248
|
.home-display-line {
|
|
@@ -50,7 +50,7 @@ if (['dark', 'light'].includes(value)) document.documentElement.style.colorSchem
|
|
|
50
50
|
<script is:inline blocking="render" set:html={themeScript}></script>
|
|
51
51
|
<link blocking="render" rel="preload" as="font" href="/fonts/GeistVariable.woff2" crossorigin="anonymous" />
|
|
52
52
|
<link blocking="render" rel="preload" as="font" href="/fonts/IBMPlexMono-Regular.woff2" crossorigin="anonymous" />
|
|
53
|
-
{(site.locale
|
|
53
|
+
{(site.locale !== 'en' || basePathname === '/brand') && <link href={`https://fonts.googleapis.com/css2?family=Noto+Sans+${({ ja: 'JP', ko: 'KR', cn: 'SC' } as Record<string, string>)[site.locale] ?? 'TC'}:wght@100..900&display=swap`} rel="stylesheet" />}
|
|
54
54
|
<style is:inline blocking="render" set:html={fontCSS}></style>
|
|
55
55
|
{/* client:load islands can hydrate while a streamed body is still parsing.
|
|
56
56
|
Their request-local context must already exist, even with cached CSS/JS. */}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, join, relative, resolve, extname, sep } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { parseFrontmatter } from '@astrojs/markdown-remark'
|
|
6
|
+
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
7
|
+
import { mdxjsEsm } from 'micromark-extension-mdxjs-esm'
|
|
8
|
+
import { mdxjsEsmFromMarkdown } from 'mdast-util-mdxjs-esm'
|
|
9
|
+
import { mdxJsx } from 'micromark-extension-mdx-jsx'
|
|
10
|
+
import { mdxJsxFromMarkdown } from 'mdast-util-mdx-jsx'
|
|
11
|
+
import * as acorn from 'acorn'
|
|
12
|
+
import { createHeadingResolver } from '../docs-shell/utils/resolve-heading.js'
|
|
13
|
+
import { locales as supportedLocales, defaultLocale as sourceLocale } from '../docs-shell/common/locales.js'
|
|
14
|
+
import { localizedMetadata } from '../docs-shell/utils/localized-metadata.ts'
|
|
15
|
+
import { isSharedMdxSample } from './mdx-sample'
|
|
16
|
+
export { localizedMetadata } from '../docs-shell/utils/localized-metadata.ts'
|
|
17
|
+
|
|
18
|
+
export interface TranslationResource {
|
|
19
|
+
id: string
|
|
20
|
+
source: string
|
|
21
|
+
translations: Record<string, string | undefined>
|
|
22
|
+
/** Source-only data or metadata whose changes require another translation review. */
|
|
23
|
+
dependencies?: Record<string, string>
|
|
24
|
+
locales?: readonly string[]
|
|
25
|
+
draft?: boolean
|
|
26
|
+
checkAnchors?: boolean
|
|
27
|
+
requireMetadata?: boolean
|
|
28
|
+
}
|
|
29
|
+
export interface TranslationReview { sourceHash: string; translationHash: string }
|
|
30
|
+
export interface TranslationReviews { version: 1; resources: Record<string, Record<string, TranslationReview>> }
|
|
31
|
+
export interface LocalizationOptions { locales?: readonly string[]; defaultLocale?: string }
|
|
32
|
+
export interface TranslationDiagnostic { id: string; locale: string; reason: 'missing' | 'unreviewed' | 'source-changed' | 'translation-changed' | 'anchors' | 'metadata'; message: string }
|
|
33
|
+
|
|
34
|
+
export const emptyReviews = (): TranslationReviews => ({ version: 1, resources: {} })
|
|
35
|
+
const hash = (value: string) => createHash('sha256').update(value.replaceAll('\r\n', '\n')).digest('hex')
|
|
36
|
+
const sourceHash = (resource: TranslationResource) => hash(JSON.stringify([resource.source, Object.entries(resource.dependencies ?? {}).sort(([a], [b]) => a.localeCompare(b))]))
|
|
37
|
+
|
|
38
|
+
/** Explicit review operation. Preparation and production builds never call this function. */
|
|
39
|
+
export function reviewTranslation(resource: TranslationResource, locale: string): TranslationReview {
|
|
40
|
+
const translation = resource.translations[locale]
|
|
41
|
+
if (!translation?.trim()) throw new Error(`Missing translation: ${resource.id} (${locale})`)
|
|
42
|
+
return { sourceHash: sourceHash(resource), translationHash: hash(translation) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function auditTranslations(resources: readonly TranslationResource[], reviews: TranslationReviews, { locales = supportedLocales, defaultLocale = sourceLocale }: LocalizationOptions = {}): TranslationDiagnostic[] {
|
|
46
|
+
if (reviews.version !== 1 || !reviews.resources) throw new Error('Unsupported translation review index; expected version 1')
|
|
47
|
+
const diagnostics: TranslationDiagnostic[] = []
|
|
48
|
+
const ids = new Set<string>()
|
|
49
|
+
for (const resource of resources) {
|
|
50
|
+
if (ids.has(resource.id)) throw new Error(`Duplicate translation resource: ${resource.id}`)
|
|
51
|
+
ids.add(resource.id)
|
|
52
|
+
if (resource.draft) continue
|
|
53
|
+
for (const locale of resource.locales ?? locales) {
|
|
54
|
+
if (locale === defaultLocale) continue
|
|
55
|
+
const report = (reason: TranslationDiagnostic['reason'], message: string) => diagnostics.push({ id: resource.id, locale, reason, message })
|
|
56
|
+
const translation = resource.translations[locale]
|
|
57
|
+
if (!translation?.trim()) { report('missing', 'Translation is missing'); continue }
|
|
58
|
+
if (parseFrontmatter(translation).frontmatter.draft === true) { report('missing', 'Translation is a draft and is not available for publication'); continue }
|
|
59
|
+
const review = reviews.resources[resource.id]?.[locale]
|
|
60
|
+
if (!review) report('unreviewed', 'Translation has not been reviewed')
|
|
61
|
+
else {
|
|
62
|
+
if (review.sourceHash !== sourceHash(resource)) report('source-changed', 'English source changed after translation review')
|
|
63
|
+
if (review.translationHash !== hash(translation)) report('translation-changed', 'Translation changed after review')
|
|
64
|
+
}
|
|
65
|
+
if (resource.checkAnchors) {
|
|
66
|
+
const expected = headingAnchors(resource.source)
|
|
67
|
+
const actual = headingAnchors(translation)
|
|
68
|
+
if (new Set(actual).size !== actual.length || JSON.stringify(expected) !== JSON.stringify(actual)) report('anchors', 'Translated heading IDs must preserve source IDs and order')
|
|
69
|
+
}
|
|
70
|
+
if (resource.requireMetadata) {
|
|
71
|
+
const { frontmatter } = parseFrontmatter(translation)
|
|
72
|
+
if (typeof frontmatter.title !== 'string' || !frontmatter.title.trim() || typeof frontmatter.description !== 'string' || !frontmatter.description.trim()) report('metadata', 'Localized MDX requires title and description frontmatter')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return diagnostics
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function assertTranslations(resources: readonly TranslationResource[], reviews: TranslationReviews, options: LocalizationOptions = {}) {
|
|
80
|
+
const diagnostics = auditTranslations(resources, reviews, options)
|
|
81
|
+
if (diagnostics.length) throw new Error(`Translation completeness check failed (${diagnostics.length}):\n${diagnostics.map(issue => `${issue.id} [${issue.locale}] ${issue.reason}: ${issue.message}`).join('\n')}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Shared Astro build gate, also usable by a consumer's explicit audit command. */
|
|
85
|
+
export async function validateSiteTranslations({ validation, ...options }: LocalizationOptions & { validation?: { reviews: URL; resources: URL } }) {
|
|
86
|
+
const locales = options.locales ?? supportedLocales
|
|
87
|
+
if (!validation) {
|
|
88
|
+
if (locales.some(locale => locale !== (options.defaultLocale ?? sourceLocale))) throw new Error('Multilingual production builds require i18n.validation resources and reviews')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const [{ default: load }, raw] = await Promise.all([import(validation.resources.href), readFile(validation.reviews, 'utf8')])
|
|
92
|
+
const resources: TranslationResource[] = typeof load === 'function' ? await load() : load
|
|
93
|
+
if (!Array.isArray(resources) || resources.length === 0) throw new Error('Translation resource loader must return a nonempty resource inventory')
|
|
94
|
+
assertTranslations(resources, JSON.parse(raw), options)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function readLocalizedDocument({ sourceFile, locale, defaultLocale = sourceLocale, metadata = {} }: { sourceFile: string; locale: string; defaultLocale?: string; metadata?: Record<string, any> }) {
|
|
98
|
+
const original = sourceFile.replace(/\.([a-z]{2})\.mdx$/, '.mdx')
|
|
99
|
+
const file = locale === defaultLocale ? original : original.replace(/\.mdx$/, `.${locale}.mdx`)
|
|
100
|
+
let source: string
|
|
101
|
+
try { source = await readFile(file, 'utf8') }
|
|
102
|
+
catch (cause) { throw new Error(`Missing localized document: ${file} (${locale})`, { cause }) }
|
|
103
|
+
const { content, frontmatter } = parseFrontmatter(source)
|
|
104
|
+
return { file, content, frontmatter, metadata: localizedMetadata(metadata, frontmatter, locale) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function filesOf(directory: string): Promise<string[]> {
|
|
108
|
+
const files: string[] = []
|
|
109
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
110
|
+
if (entry.name.startsWith('.')) continue
|
|
111
|
+
const file = join(directory, entry.name)
|
|
112
|
+
if (entry.isDirectory()) { if (entry.name !== 'drafts') files.push(...await filesOf(file)) }
|
|
113
|
+
else files.push(file)
|
|
114
|
+
}
|
|
115
|
+
return files.sort()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Filesystem inventory, with no page imports, component execution or generated source writes. */
|
|
119
|
+
export async function discoverDocumentResources({ contentRoot, locales = supportedLocales, defaultLocale = sourceLocale, exclude = [], siteRoot = dirname(contentRoot) }: { contentRoot: string; siteRoot?: string; exclude?: readonly string[] } & LocalizationOptions): Promise<TranslationResource[]> {
|
|
120
|
+
const files = new Set(await filesOf(contentRoot))
|
|
121
|
+
const resources: TranslationResource[] = []
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
if (!file.endsWith('.mdx') || /\.[a-z]{2}\.mdx$/.test(file)) continue
|
|
124
|
+
const source = await readFile(file, 'utf8')
|
|
125
|
+
const id = relative(contentRoot, file).replaceAll('\\', '/')
|
|
126
|
+
if (!source.trim() || exclude.includes(id)) continue
|
|
127
|
+
// Imported examples remain part of the owner's source dependency digest.
|
|
128
|
+
if (isSharedMdxSample(file, source)) continue
|
|
129
|
+
const metadata = join(dirname(file), 'metadata.ts')
|
|
130
|
+
const sourceMetadata = files.has(metadata) ? await readFile(metadata, 'utf8') : undefined
|
|
131
|
+
const dependencies = await collectSourceDependencies([file, ...(sourceMetadata ? [metadata] : [])], { siteRoot })
|
|
132
|
+
delete dependencies[relative(siteRoot, file).replaceAll('\\', '/')]
|
|
133
|
+
const translations: Record<string, string> = {}
|
|
134
|
+
for (const locale of locales) {
|
|
135
|
+
if (locale === defaultLocale) continue
|
|
136
|
+
const translatedFile = file.replace(/\.mdx$/, `.${locale}.mdx`)
|
|
137
|
+
if (files.has(translatedFile)) translations[locale] = await readFile(translatedFile, 'utf8')
|
|
138
|
+
}
|
|
139
|
+
resources.push({ id, source, translations, dependencies, checkAnchors: true, requireMetadata: basename(file) === 'content.mdx', draft: parseFrontmatter(source).frontmatter.draft === true || /\bdraft\s*:\s*true\b/.test(sourceMetadata ?? '') })
|
|
140
|
+
}
|
|
141
|
+
return resources
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Literal interface requests must exist even when a newly added control has no dictionary entry yet. */
|
|
145
|
+
export async function sharedInterfaceKeys(sourceRoot = join(dirname(fileURLToPath(import.meta.resolve('@master/internal-site/package.json'))), 'src')): Promise<string[]> {
|
|
146
|
+
const keys = new Set<string>()
|
|
147
|
+
const visit = async (directory: string) => {
|
|
148
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
149
|
+
const file = join(directory, entry.name)
|
|
150
|
+
if (entry.isDirectory()) await visit(file)
|
|
151
|
+
else if (/\.(?:astro|svelte|ts|js)$/.test(entry.name) && !/\.(?:test|spec)\./.test(entry.name)) {
|
|
152
|
+
const source = await readFile(file, 'utf8')
|
|
153
|
+
for (const match of source.matchAll(/\bt\s*\(\s*(?=['"`])/g)) {
|
|
154
|
+
const expression = acorn.parseExpressionAt(source, match.index! + match[0].length, { ecmaVersion: 'latest' }) as any
|
|
155
|
+
const value = expression.type === 'Literal' ? expression.value : expression.type === 'TemplateLiteral' && !expression.expressions.length ? expression.quasis[0].value.cooked : undefined
|
|
156
|
+
if (typeof value === 'string' && value.trim()) keys.add(value)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
await visit(sourceRoot)
|
|
162
|
+
return [...keys].sort()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Follow local prose/data modules without executing them or importing product compiler code. */
|
|
166
|
+
export async function collectSourceDependencies(files: readonly string[], { siteRoot }: { siteRoot: string }): Promise<Record<string, string>> {
|
|
167
|
+
const root = resolve(siteRoot)
|
|
168
|
+
const sharedRoot = dirname(fileURLToPath(import.meta.resolve('@master/internal-site/package.json')))
|
|
169
|
+
const collected: Record<string, string> = {}
|
|
170
|
+
const seen = new Set<string>()
|
|
171
|
+
const visit = async (file: string) => {
|
|
172
|
+
file = resolve(file).replace(/\.(tw|ja|ko|cn)\.mdx$/, '.mdx')
|
|
173
|
+
if ((!file.startsWith(root + sep) && !file.startsWith(sharedRoot + sep)) || seen.has(file)) return
|
|
174
|
+
// Localized dictionaries have their own review resource. Their translations
|
|
175
|
+
// must not masquerade as a change to every article's English source.
|
|
176
|
+
if (file.startsWith(sharedRoot + sep) && /\/common\/(?:translations|locales)\.[jt]s$/.test(file.replaceAll('\\', '/'))) return
|
|
177
|
+
seen.add(file)
|
|
178
|
+
const source = await readFile(file, 'utf8').catch(() => undefined)
|
|
179
|
+
if (source === undefined) return
|
|
180
|
+
const key = file.startsWith(sharedRoot + sep) ? `@master/internal-site/${relative(sharedRoot, file)}` : relative(root, file)
|
|
181
|
+
collected[key.replaceAll('\\', '/')] = source
|
|
182
|
+
const imports = source.matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)(['"])([^'"\n]+)\1/g)
|
|
183
|
+
for (const [, , specifier] of imports) {
|
|
184
|
+
if (!specifier.startsWith('.') && !specifier.startsWith('~/site/') && !specifier.startsWith('@master/internal-site/')) continue
|
|
185
|
+
let target: string
|
|
186
|
+
try {
|
|
187
|
+
target = specifier.startsWith('@master/internal-site/') ? fileURLToPath(import.meta.resolve(specifier.split('?')[0]))
|
|
188
|
+
: specifier.startsWith('~/site/') ? resolve(root, specifier.slice(7)) : resolve(dirname(file), specifier.split('?')[0])
|
|
189
|
+
} catch { continue }
|
|
190
|
+
if (extname(target) && !/\.(?:[cm]?[jt]s|[jt]sx|mdx|astro|svelte)$/.test(target)) continue
|
|
191
|
+
const candidates = extname(target) ? [target, ...(/\.js$/.test(target) ? [target.replace(/\.js$/, '.ts')] : [])] : ['', '.ts', '.js', '.mdx', '.astro', '.svelte', '/index.ts', '/index.js'].map(suffix => target + suffix)
|
|
192
|
+
for (const candidate of candidates) {
|
|
193
|
+
const before = Object.keys(collected).length
|
|
194
|
+
await visit(candidate)
|
|
195
|
+
if (Object.keys(collected).length > before) break
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const file of files) await visit(file)
|
|
200
|
+
return Object.fromEntries(Object.entries(collected).sort(([a], [b]) => a.localeCompare(b)))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function headings(source: string) {
|
|
204
|
+
const parsed = parseFrontmatter(source)
|
|
205
|
+
const offset = source.indexOf(parsed.content, parsed.rawFrontmatter ? parsed.rawFrontmatter.length : 0)
|
|
206
|
+
const tree = fromMarkdown(parsed.content, {
|
|
207
|
+
extensions: [{ disable: { null: ['htmlFlow', 'htmlText', 'codeIndented'] } }, mdxjsEsm({ acorn, addResult: true }), mdxJsx({ acorn, addResult: true })],
|
|
208
|
+
mdastExtensions: [mdxjsEsmFromMarkdown(), mdxJsxFromMarkdown()]
|
|
209
|
+
})
|
|
210
|
+
const resolveHeading = createHeadingResolver()
|
|
211
|
+
const found: { id: string; explicit: boolean; offset: number }[] = []
|
|
212
|
+
const visit = (node: any) => {
|
|
213
|
+
if (node.type === 'heading' && node.depth >= 2 && node.depth <= 6) {
|
|
214
|
+
const title = node.children.filter((child: any) => child.type === 'text' || child.type === 'inlineCode').map((child: any) => child.value).join(' ').trim()
|
|
215
|
+
const resolved = resolveHeading(title, node.depth)
|
|
216
|
+
const raw = parsed.content.slice(node.position.start.offset, node.position.end.offset)
|
|
217
|
+
const end = raw.includes('\n') ? node.position.start.offset + raw.indexOf('\n') : node.position.end.offset
|
|
218
|
+
found.push({ id: resolved.id ?? '', explicit: resolved.explicit, offset: Math.max(0, offset) + end })
|
|
219
|
+
}
|
|
220
|
+
node.children?.forEach(visit)
|
|
221
|
+
}
|
|
222
|
+
visit(tree)
|
|
223
|
+
return found
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function headingAnchors(source: string): string[] { return headings(source).map(heading => heading.id) }
|
|
227
|
+
|
|
228
|
+
/** Pin the existing automatic English IDs before translation, without modifying fenced examples. */
|
|
229
|
+
export function stabilizeHeadingAnchors(source: string): string {
|
|
230
|
+
for (const heading of headings(source).reverse()) {
|
|
231
|
+
if (heading.explicit || !heading.id) continue
|
|
232
|
+
// Closing ATX hashes are syntax; the ID belongs before them.
|
|
233
|
+
const before = source.slice(0, heading.offset)
|
|
234
|
+
const closing = before.match(/[ \t]+#+[ \t]*$/)?.[0] ?? ''
|
|
235
|
+
const at = heading.offset - closing.length
|
|
236
|
+
source = source.slice(0, at) + ` \\{#${heading.id}\\}` + source.slice(at)
|
|
237
|
+
}
|
|
238
|
+
return source
|
|
239
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { parseFrontmatter } from '@astrojs/markdown-remark'
|
|
3
|
+
import { fromMarkdown } from 'mdast-util-from-markdown'
|
|
4
|
+
|
|
5
|
+
/** Only prose-free companion examples share one source across editions. */
|
|
6
|
+
export function isSharedMdxSample(file: string, source: string): boolean {
|
|
7
|
+
if (['content.mdx', 'contract.mdx'].includes(basename(file))) return false
|
|
8
|
+
const { content, frontmatter } = parseFrontmatter(source)
|
|
9
|
+
if (Object.keys(frontmatter).length) return false
|
|
10
|
+
const nodes = fromMarkdown(content).children
|
|
11
|
+
return nodes.length > 0 && nodes.every(node => node.type === 'code')
|
|
12
|
+
}
|