@master/internal-site 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +37 -0
  2. package/dist/astro.mjs +92 -41
  3. package/dist/config.d.mts +6 -0
  4. package/dist/localization-LfIbIysE.mjs +356 -0
  5. package/dist/localization.d.mts +69 -0
  6. package/dist/localization.mjs +1 -0
  7. package/package.json +12 -9
  8. package/src/astro/content-pages.ts +30 -11
  9. package/src/astro/index.ts +9 -5
  10. package/src/astro/localized-mdx.ts +45 -0
  11. package/src/astro/routes.template.ts +3 -0
  12. package/src/astro/site-context.ts +10 -2
  13. package/src/benchmarks/BenchmarkChart.astro +4 -1
  14. package/src/benchmarks/BenchmarkMetricTable.astro +1 -1
  15. package/src/benchmarks/BenchmarkStackedBars.astro +2 -2
  16. package/src/benchmarks/BenchmarkStatus.astro +3 -1
  17. package/src/benchmarks/localization.ts +22 -0
  18. package/src/blog/article/BlogArticleEnd.astro +6 -9
  19. package/src/blog/article/BlogArticleMasthead.astro +5 -8
  20. package/src/config.ts +3 -0
  21. package/src/css/foundation-data/specimen-content.ts +2 -2
  22. package/src/css/markdown.ts +52 -11
  23. package/src/demo/Demo.svelte +6 -4
  24. package/src/demo/DemoAsset.astro +5 -3
  25. package/src/demo/DemoAxes.svelte +4 -2
  26. package/src/demo/DemoCatalog.astro +8 -6
  27. package/src/demo/DemoContainer.svelte +5 -3
  28. package/src/demo/DemoControls.svelte +3 -1
  29. package/src/demo/DemoCopyButton.svelte +6 -4
  30. package/src/demo/DemoCopyGroup.svelte +4 -2
  31. package/src/demo/DemoExample.astro +3 -1
  32. package/src/demo/DemoFeatureSupport.svelte +3 -1
  33. package/src/demo/DemoGeneratedCSS.svelte +3 -1
  34. package/src/demo/DemoIndex.astro +5 -3
  35. package/src/demo/DemoLegend.svelte +3 -1
  36. package/src/demo/DemoMeasure.svelte +4 -2
  37. package/src/demo/DemoMedia.svelte +3 -1
  38. package/src/demo/DemoMotion.svelte +5 -3
  39. package/src/demo/DemoPaletteGroups.svelte +4 -2
  40. package/src/demo/DemoStyleComparison.astro +5 -3
  41. package/src/demo/DemoThemeComparison.astro +4 -2
  42. package/src/demo/DemoTokenTable.astro +4 -2
  43. package/src/demo/DemoViewTransition.astro +3 -1
  44. package/src/demo/DemoViewport.svelte +13 -11
  45. package/src/demo/DemoWaterfall.astro +3 -1
  46. package/src/demo/ProjectStyleExample.astro +4 -1
  47. package/src/demo/foundations/FoundationDialog.astro +5 -2
  48. package/src/demo/foundations/FoundationTokensView.astro +16 -5
  49. package/src/demo/foundations/Palette.svelte +9 -7
  50. package/src/demo/foundations/dialog-copy.d.ts +1 -0
  51. package/src/demo/foundations/dialog-copy.js +5 -0
  52. package/src/docs-shell/common/i18n.config.d.ts +1 -1
  53. package/src/docs-shell/common/i18n.config.js +4 -12
  54. package/src/docs-shell/common/locales.d.ts +8 -0
  55. package/src/docs-shell/common/locales.js +18 -0
  56. package/src/docs-shell/common/terminology.json +32 -0
  57. package/src/docs-shell/common/translations.d.ts +3 -0
  58. package/src/docs-shell/common/translations.js +294 -0
  59. package/src/docs-shell/components/ArticleHeader.astro +3 -3
  60. package/src/docs-shell/components/CodeCopyButton.astro +3 -1
  61. package/src/docs-shell/components/CodeTabBar.astro +5 -3
  62. package/src/docs-shell/components/DocHeader.svelte +3 -4
  63. package/src/docs-shell/components/DocHeading.astro +3 -1
  64. package/src/docs-shell/components/DocMenuButton.svelte +4 -5
  65. package/src/docs-shell/components/DocSidebar.svelte +1 -2
  66. package/src/docs-shell/components/DocumentCodeTable.astro +3 -1
  67. package/src/docs-shell/components/DocumentIllustration.astro +3 -1
  68. package/src/docs-shell/components/DocumentKeyList.astro +5 -3
  69. package/src/docs-shell/components/DocumentNamespaceTable.astro +4 -2
  70. package/src/docs-shell/components/DocumentOptions.astro +3 -1
  71. package/src/docs-shell/components/DocumentationIndex.svelte +6 -7
  72. package/src/docs-shell/components/GeneratedCSS.astro +3 -1
  73. package/src/docs-shell/components/Link.svelte +3 -2
  74. package/src/docs-shell/components/NotFound.astro +3 -4
  75. package/src/docs-shell/components/PageNavs.astro +2 -3
  76. package/src/docs-shell/components/SearchDialog.svelte +13 -16
  77. package/src/docs-shell/components/TimeAgo.svelte +6 -6
  78. package/src/docs-shell/contexts/search.ts +1 -1
  79. package/src/docs-shell/factories/Page.astro +10 -5
  80. package/src/docs-shell/factories/create-page.ts +33 -6
  81. package/src/docs-shell/layouts/BlogArticle.astro +3 -11
  82. package/src/docs-shell/remark/slug-and-toc.js +5 -7
  83. package/src/docs-shell/scripts/code.ts +5 -2
  84. package/src/docs-shell/site.ts +9 -1
  85. package/src/docs-shell/utils/document-copy.d.ts +2 -0
  86. package/src/docs-shell/utils/document-copy.js +18 -0
  87. package/src/docs-shell/utils/generate-metadata.ts +2 -0
  88. package/src/docs-shell/utils/i18n-pathname.ts +13 -2
  89. package/src/docs-shell/utils/i18n.ts +4 -1
  90. package/src/docs-shell/utils/localized-metadata.ts +22 -0
  91. package/src/docs-shell/utils/markdown-links.ts +61 -0
  92. package/src/docs-shell/utils/markdown-tree.ts +19 -6
  93. package/src/docs-shell/utils/resolve-heading.d.ts +1 -0
  94. package/src/docs-shell/utils/resolve-heading.js +21 -0
  95. package/src/docs-shell/utils/search-pages.ts +75 -22
  96. package/src/home/hero.css +5 -0
  97. package/src/layouts/Document.astro +1 -1
  98. package/src/scripts/localization.ts +239 -0
  99. package/src/scripts/mdx-sample.ts +12 -0
  100. package/src/scripts/static-social.ts +17 -3
  101. package/src/scripts/style-delivery.mjs +14 -4
  102. package/src/sections/guide.astro +3 -1
  103. package/src/virtual.d.ts +5 -1
  104. 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 Slugger from 'github-slugger'
4
- import resolveHeading from './resolve-heading'
3
+ import resolveHeading, { createHeadingResolver } from './resolve-heading'
5
4
 
6
5
  const parser = remark().use(gfm)
7
- export const markdownTree = (source: string) => parser.parse(source)
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, slugger.slug(title))
16
- headings.push({ id: resolved.id!, title: resolved.title, depth: node.depth })
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 +1,2 @@
1
1
  export default function resolveHeading(title: string, id?: string): { title: string; id?: string; className?: string }
2
+ export function createHeadingResolver(): (title: string, depth: number) => { title: string; id?: string; className?: string; explicit: boolean }
@@ -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 { fromMarkdown } from 'mdast-util-from-markdown'
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 resolveHeading from './resolve-heading.js'
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 { metadata } = entry
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 = entry.localizedContent?.[locale] ?? entry.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 = fromMarkdown(source, {
174
- extensions: [mdxjsEsm({ acorn, addResult: true })],
175
- mdastExtensions: [mdxjsEsmFromMarkdown()]
176
- }) as MdxNode
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 slugger = new Slugger()
179
- appendSearchNodes(tree.children ?? [], nodes, slugger)
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[], slugger: Slugger) {
232
+ function appendSearchNodes(children: MdxNode[], nodes: SearchNode[], resolveHeading: ReturnType<typeof createHeadingResolver>) {
184
233
  for (const child of children) {
185
- appendSearchNode(child, nodes, slugger)
234
+ appendSearchNode(child, nodes, resolveHeading)
186
235
  }
187
236
  }
188
237
 
189
- function appendSearchNode(node: MdxNode, nodes: SearchNode[], slugger: Slugger) {
238
+ function appendSearchNode(node: MdxNode, nodes: SearchNode[], resolveHeading: ReturnType<typeof createHeadingResolver>) {
190
239
  switch (node.type) {
191
240
  case 'heading':
192
- appendHeadingSearchNode(node, nodes, slugger)
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, slugger)
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, slugger)
267
+ appendSearchNodes(node.children, nodes, resolveHeading)
216
268
  }
217
269
  }
218
270
  }
219
271
 
220
- function appendHeadingSearchNode(node: MdxNode, nodes: SearchNode[], slugger: Slugger) {
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 === 2 || node.depth === 3) {
226
- const resolved = resolveHeading(text, slugger.slug(text))
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 === 'tw' || basePathname === '/brand') && <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@100..900&display=swap" rel="stylesheet" />}
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
+ }