@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.
Files changed (105) 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/styles/docs-shell/prose.css +1 -1
  104. package/src/virtual.d.ts +5 -1
  105. package/src/blog/article/language.ts +0 -29
@@ -1,5 +1,7 @@
1
1
  ---
2
2
  import '@master/internal-site/styles/system/disclosure.css'
3
+ import { siteOf } from '../../astro/site-context'
4
+ const { t } = siteOf(Astro)
3
5
 
4
6
  interface Props {
5
7
  keys: string[]
@@ -11,12 +13,12 @@ const { keys, previewCount = keys.length } = Astro.props
11
13
  const lists = [keys.slice(0, previewCount), keys.slice(previewCount)]
12
14
  ---
13
15
 
14
- <ul aria-label="Utility keys">
16
+ <ul aria-label={t('Utility keys')}>
15
17
  {lists[0].map(key => <li><code>{key}</code></li>)}
16
18
  </ul>
17
19
  {keys.length > previewCount && <details class="ui-disclosure">
18
- <summary>Show {keys.length - previewCount} more keys</summary>
19
- <ul aria-label="Utility keys">
20
+ <summary>{t('Show {count} more keys').replace('{count}', String(keys.length - previewCount))}</summary>
21
+ <ul aria-label={t('Utility keys')}>
20
22
  {lists[1].map(key => <li><code>{key}</code></li>)}
21
23
  </ul>
22
24
  </details>}
@@ -1,6 +1,8 @@
1
1
  ---
2
2
  import '@master/internal-site/styles/system/table.css'
3
3
  import DocumentKeyList from '@master/internal-site/components/DocumentKeyList.astro'
4
+ import { siteOf } from '../../astro/site-context'
5
+ const { t } = siteOf(Astro)
4
6
 
5
7
  interface Props {
6
8
  rows: { namespace: string, consumers: string[] }[]
@@ -11,8 +13,8 @@ const { rows } = Astro.props
11
13
  ---
12
14
 
13
15
  {/* The table's narrow overflow region must be focusable for native keyboard scrolling. */}
14
- <div class="ui-table" data-variant="lookup" role="region" aria-label="Namespace consumers" tabindex="0"><table>
15
- <thead><tr><th scope="col">Namespace</th><th scope="col">Consumers</th></tr></thead>
16
+ <div class="ui-table" data-variant="lookup" role="region" aria-label={t('Namespace consumers')} tabindex="0"><table>
17
+ <thead><tr><th scope="col">{t('Namespace')}</th><th scope="col">{t('Consumers')}</th></tr></thead>
16
18
  <tbody>{rows.map(({ namespace, consumers }) => <tr>
17
19
  <th scope="row"><code>{namespace}-*</code></th>
18
20
  <td><DocumentKeyList keys={consumers} previewCount={6} /></td>
@@ -1,4 +1,6 @@
1
1
  ---
2
+ import { siteOf } from '../../astro/site-context'
3
+ const { t } = siteOf(Astro)
2
4
  import RenderNode from '@master/internal-site/components/RenderNode.astro'
3
5
  import DocumentOptionEntry from '@master/internal-site/components/DocumentOptionEntry.astro'
4
6
  import DocumentOptionList from '@master/internal-site/components/DocumentOptionList.astro'
@@ -16,7 +18,7 @@ const { label, options } = Astro.props
16
18
 
17
19
  <DocumentOptionList label={label}>
18
20
  {options.map(option => <DocumentOptionEntry name={option.name}>
19
- {option.defaultValue !== undefined && <small>Default <code>{option.defaultValue}</code></small>}
21
+ {option.defaultValue !== undefined && <small>{t('Default')} <code>{option.defaultValue}</code></small>}
20
22
  <p><RenderNode node={option.description} /></p>
21
23
  </DocumentOptionEntry>)}
22
24
  </DocumentOptionList>
@@ -26,7 +26,6 @@
26
26
  } = $props()
27
27
 
28
28
  const { locale, t } = getSite()
29
- const tw = locale === 'tw'
30
29
  let alphabetical = $state(false)
31
30
  const entries = $derived(sections.flatMap(section => section.groups.flatMap(group => group.entries)))
32
31
  const letters = $derived(Map.groupBy([...entries].sort((a, b) => a.title.localeCompare(b.title)), entry => /^[a-z]/i.test(entry.title) ? entry.title[0].toUpperCase() : '#'))
@@ -73,7 +72,7 @@
73
72
 
74
73
  <div class="documentation-index">
75
74
  <!-- A contents list, not a second catalog: each section by its number, name and size; the sibling index closes it. -->
76
- <nav class="documentation-index-directory" aria-label={tw ? `${name === 'guide' ? 'Guide' : 'Reference'} 分區` : `${name === 'guide' ? 'Guide' : 'Reference'} sections`}>
75
+ <nav class="documentation-index-directory" aria-label={t('{name} sections').replace('{name}', t(name === 'guide' ? 'Guide' : 'Reference'))}>
77
76
  {#each sections as section, index (section.id)}
78
77
  <a href={`#${section.id}`} onclick={() => alphabetical = false}>
79
78
  <small>{ordinal(index)}</small>
@@ -89,9 +88,9 @@
89
88
  </nav>
90
89
 
91
90
  <div class="documentation-index-toolbar">
92
- <p>{tw ? (name === 'guide' ? '所有教學' : '所有條目') : (name === 'guide' ? 'All guides' : 'All entries')} <span class="documentation-index-count">{entries.length}</span></p>
93
- <div class="ui-segmented" data-variant="underline" role="group" aria-label={tw ? '索引顯示方式' : 'Index view'}>
94
- <button type="button" aria-pressed={!alphabetical} aria-controls={panelId('category')} onclick={() => alphabetical = false}>{tw ? '依分類' : 'By category'}</button>
91
+ <p>{t(name === 'guide' ? 'All guides' : 'All entries')} <span class="documentation-index-count">{entries.length}</span></p>
92
+ <div class="ui-segmented" data-variant="underline" role="group" aria-label={t('Index view')}>
93
+ <button type="button" aria-pressed={!alphabetical} aria-controls={panelId('category')} onclick={() => alphabetical = false}>{t('By category')}</button>
95
94
  <button type="button" aria-pressed={alphabetical} aria-controls={panelId('alphabetical')} onclick={() => alphabetical = true}>A–Z</button>
96
95
  </div>
97
96
  </div>
@@ -104,7 +103,7 @@
104
103
  <h2 id={section.id}>{section.title} <span class="documentation-index-count">{count(section)}</span></h2>
105
104
  <p>{section.description}</p>
106
105
  {#if section.groups.length > 1}
107
- <nav class="documentation-index-groups" aria-label={tw ? `${section.title}分類` : `${section.title} categories`}>
106
+ <nav class="documentation-index-groups" aria-label={t('{name} categories').replace('{name}', section.title)}>
108
107
  {#each section.groups as group, index (groupId(section, index))}
109
108
  <a href={`#${groupId(section, index)}`}>{group.title}</a>
110
109
  {/each}
@@ -128,7 +127,7 @@
128
127
  </div>
129
128
 
130
129
  <div id={panelId('alphabetical')} hidden={!alphabetical}>
131
- <nav class="documentation-index-letters" aria-label={tw ? '依字母跳轉' : 'Jump to a letter'}>
130
+ <nav class="documentation-index-letters" aria-label={t('Jump to a letter')}>
132
131
  {#each [...letters.keys()] as letter (letter)}
133
132
  <a href={`#${letterId(letter)}`}>{letter}</a>
134
133
  {/each}
@@ -1,9 +1,11 @@
1
1
  ---
2
2
  import '@master/internal-site/styles/system/disclosure.css'
3
+ import { siteOf } from '../../astro/site-context'
4
+ const { t } = siteOf(Astro)
3
5
  /** A closed native disclosure keeps the complete output in the server HTML. */
4
6
  ---
5
7
 
6
8
  <details class="ui-disclosure" data-generated="">
7
- <summary>Generated CSS</summary>
9
+ <summary>{t('Generated CSS')}</summary>
8
10
  <slot />
9
11
  </details>
@@ -58,12 +58,13 @@
58
58
  const isHash = $derived(props.href?.startsWith('#'))
59
59
  const external = $derived(props.href?.startsWith('http'))
60
60
  const href: string = $derived.by(() => {
61
- if (!props.href?.startsWith('/')) return props.href || ''
61
+ if (!props.href) return ''
62
62
  return localizePathname(props.href, {
63
63
  defaultLocale: i18n.defaultLocale,
64
64
  locale,
65
65
  locales: i18n.locales,
66
- localizablePathnameRoots: i18n.localizablePathnameRoots
66
+ localizablePathnameRoots: i18n.localizablePathnameRoots,
67
+ localizableOrigins: i18n.localizableOrigins
67
68
  }) || ''
68
69
  })
69
70
  const canonicalHref = $derived(canonicalizeDefaultLocalePathname(href, i18n.defaultLocale))
@@ -8,8 +8,7 @@ import SearchButton from './SearchButton.svelte'
8
8
  import Link from './Link.svelte'
9
9
  import config from 'virtual:master-site/config'
10
10
 
11
- const { t: $, locale } = siteOf(Astro)
12
- const tw = locale === 'tw'
11
+ const { t: $ } = siteOf(Astro)
13
12
  const guide = Boolean(config.sections.guide)
14
13
  ---
15
14
 
@@ -17,8 +16,8 @@ const guide = Boolean(config.sections.guide)
17
16
  <div class="ui-pagehead">
18
17
  <span class="ui-pagehead-kicker">404</span>
19
18
  <h1>{$('This page does not exist')}</h1>
20
- <p>{guide ? (tw ? '試著搜尋文件,或返回 Guide 繼續閱讀。' : 'Try searching the documentation or return to the Guide.') : (tw ? '試著搜尋網站,或返回首頁。' : 'Try searching the site or return to the homepage.')}</p>
19
+ <p>{guide ? $('Try searching the documentation or return to the Guide.') : $('Try searching the site or return to the homepage.')}</p>
21
20
  </div>
22
21
  <SearchButton client:load class="ui-search-trigger" />
23
- <Link href={guide ? "/guide" : "/"} class="ui-button" data-variant="quiet">{guide ? (tw ? '瀏覽 Guide' : 'Browse the Guide') : (tw ? '返回首頁' : 'Back to home')}<span aria-hidden="true">→</span></Link>
22
+ <Link href={guide ? "/guide" : "/"} class="ui-button" data-variant="quiet">{guide ? $('Browse the Guide') : $('Back to home')}<span aria-hidden="true">→</span></Link>
24
23
  </main>
@@ -13,7 +13,6 @@ interface Props {
13
13
  /** The previous and next documents in the section's order, as a `ui-pager`. */
14
14
  const { pageCategories, metadata, locale, dictionaries } = Astro.props
15
15
  const $ = await createTranslation(locale, dictionaries)
16
- const tw = locale === 'tw'
17
16
  const unit = metadata?.pathname?.split('/')?.[1]
18
17
  const pages = (pageCategories?.flatMap((category: any) => category.pages) || [])
19
18
  .filter(({ pathname }: any) => pathname.split('/').length <= 3 && unit)
@@ -26,13 +25,13 @@ const navs = [
26
25
  ].filter(Boolean).map(({ direction, definedMetadata }: any) => ({
27
26
  direction,
28
27
  href: definedMetadata.pathname,
29
- label: direction === 'prev' ? (tw ? '上一篇' : 'Previous') : (tw ? '下一篇' : 'Next'),
28
+ label: direction === 'prev' ? $('Previous') : $('Next'),
30
29
  title: $(definedMetadata.title.absolute || definedMetadata.title)
31
30
  }))
32
31
  ---
33
32
 
34
33
  {navs.length > 0 && (
35
- <nav class="ui-pager" aria-label={tw ? '前後篇文件' : 'Previous and next documents'}>
34
+ <nav class="ui-pager" aria-label={$('Previous and next documents')}>
36
35
  {navs.map(({ direction, href, label, title }) => <Link {href} rel={direction} aria-label={`${label}: ${title}`}>
37
36
  <small>{label}</small>
38
37
  <b>{title}</b>
@@ -38,7 +38,6 @@
38
38
 
39
39
  /** The page's single search dialog; search triggers in other islands open it by event (`contexts/search`). */
40
40
  const { t, locale } = getSite()
41
- const tw = locale === 'tw'
42
41
  let opened = $state(false)
43
42
  let query = $state('')
44
43
  let shortcutModifier = $state('⌘')
@@ -130,10 +129,8 @@
130
129
  }
131
130
  }
132
131
 
133
- const reasons: Record<string, string> | undefined = tw ? { Identifier: '識別字', Title: '名稱', Syntax: '語法與範例', Topic: '主題', Content: '內文' } : undefined
134
- const kinds: Record<string, string> = tw
135
- ? { utility: 'Utility', rule: '規則', tokens: 'Tokens', directive: '指令', tool: '工具', package: 'API' }
136
- : { utility: 'Utility', rule: 'Rule', tokens: 'Tokens', directive: 'Directive', tool: 'Tool', package: 'API' }
132
+ const kinds: Record<string, string> = { utility: 'Utility', rule: 'Rule', tokens: 'Tokens', directive: 'Directive', tool: 'Tool', package: 'API' }
133
+
137
134
  </script>
138
135
 
139
136
  <svelte:window onkeydown={shortcut} />
@@ -147,33 +144,33 @@
147
144
  {/snippet}
148
145
 
149
146
  {#if opened}
150
- <dialog bind:this={dialog} {@attach portal} {@attach closeOnBackdropClick} class="ui-dialog" data-variant="search" aria-label={tw ? '搜尋文件' : 'Search documentation'} oncancel={close}>
147
+ <dialog bind:this={dialog} {@attach portal} {@attach closeOnBackdropClick} class="ui-dialog" data-variant="search" aria-label={t('Search documentation')} oncancel={close}>
151
148
  <div class="ui-dialog-search">
152
149
  <IconSearch size={18} stroke={1.5} aria-hidden="true" />
153
- <input bind:this={input} type="search" role="combobox" aria-autocomplete="list" aria-expanded={Boolean(query)} value={query} autocomplete="off" spellcheck={false} placeholder={tw ? '搜尋文件…' : 'Search documentation…'} aria-label={tw ? '搜尋文件' : 'Search documentation'} aria-controls="documentation-search-results" aria-activedescendant={results[selected] ? `documentation-result-${selected}` : undefined}
150
+ <input bind:this={input} type="search" role="combobox" aria-autocomplete="list" aria-expanded={Boolean(query)} value={query} autocomplete="off" spellcheck={false} placeholder={t('Search documentation…')} aria-label={t('Search documentation')} aria-controls="documentation-search-results" aria-activedescendant={results[selected] ? `documentation-result-${selected}` : undefined}
154
151
  oninput={event => selectQuery(event.currentTarget.value)}
155
152
  {onkeydown} />
156
- {#if query}<button type="button" class="ui-button" data-variant="quiet" data-shape="icon" data-size="sm" onclick={() => selectQuery('')} aria-label={tw ? '清除搜尋' : 'Clear search'}><IconX size={16} stroke={1.5} aria-hidden="true" /></button>{/if}
157
- <button type="button" class="ui-button ui-dialog-close" data-variant="quiet" data-size="sm" onclick={close} aria-label={tw ? '關閉搜尋' : 'Close search'}><span class="ui-kbd">esc</span><span>{tw ? '取消' : 'Cancel'}</span></button>
153
+ {#if query}<button type="button" class="ui-button" data-variant="quiet" data-shape="icon" data-size="sm" onclick={() => selectQuery('')} aria-label={t('Clear search')}><IconX size={16} stroke={1.5} aria-hidden="true" /></button>{/if}
154
+ <button type="button" class="ui-button ui-dialog-close" data-variant="quiet" data-size="sm" onclick={close} aria-label={t('Close search')}><span class="ui-kbd">esc</span><span>{t('Cancel')}</span></button>
158
155
  </div>
159
156
  <div class="ui-dialog-body" bind:this={scroll}>
160
- <p class="ui-dialog-status" role="status">{loading ? (tw ? '正在載入文件…' : 'Loading documentation…') : error ? (tw ? '搜尋暫時無法載入。' : 'Search could not load.') : query ? `${results.length === 40 ? (tw ? '前 ' : 'Top ') : ''}${results.length} ${tw ? '筆結果' : results.length === 1 ? 'result' : 'results'}` : (tw ? '依名稱、別名或 CSS 屬性查找' : 'Find a name, alias or CSS property')}</p>
161
- {#if error}<div class="ui-dialog-note"><p>{tw ? '請檢查連線,再試一次。' : 'Check your connection and try again.'}</p><button type="button" class="ui-button" data-size="sm" onclick={() => { retry += 1; input?.focus() }}>{tw ? '再試一次' : 'Try again'}</button></div>{/if}
157
+ <p class="ui-dialog-status" role="status">{loading ? (t('Loading documentation…')) : error ? (t('Search could not load.')) : query ? t(results.length === 40 ? 'Top {count} results' : '{count} results').replace('{count}', String(results.length)) : (t('Find a name, alias or CSS property'))}</p>
158
+ {#if error}<div class="ui-dialog-note"><p>{t('Check your connection and try again.')}</p><button type="button" class="ui-button" data-size="sm" onclick={() => { retry += 1; input?.focus() }}>{t('Try again')}</button></div>{/if}
162
159
  {#if !query && !error && !loading}<div class="ui-dialog-note">
163
- <p>{tw ? '試試看' : 'Try searching'}</p>
160
+ <p>{t('Try searching')}</p>
164
161
  {#each ['padding', 'padding-inline-start:', 'fg-red:hover@sm'] as value (value)}<button type="button" class="ui-button" data-size="sm" onclick={() => selectQuery(value)}><code>{value}</code><IconCornerDownLeft size={14} stroke={1.5} aria-hidden="true" /></button>{/each}
165
162
  </div>{/if}
166
- {#if query && !results.length && !error && !loading}<div class="ui-dialog-note" data-align="center"><IconSearch size={24} stroke={1.25} aria-hidden="true" /><b>{tw ? '找不到相符的文件' : 'No matching documents'}</b><p>{tw ? '試試屬性全名、token 名稱或其他關鍵字。' : 'Try a property name, a token name or a different keyword.'}</p></div>{/if}
167
- <ol id="documentation-search-results" class="ui-results" role="listbox" aria-label={tw ? '搜尋結果' : 'Search results'} aria-busy={loading}>
163
+ {#if query && !results.length && !error && !loading}<div class="ui-dialog-note" data-align="center"><IconSearch size={24} stroke={1.25} aria-hidden="true" /><b>{t('No matching documents')}</b><p>{t('Try a property name, a token name or a different keyword.')}</p></div>{/if}
164
+ <ol id="documentation-search-results" class="ui-results" role="listbox" aria-label={t('Search results')} aria-busy={loading}>
168
165
  {#if !error}{#each results as result, index (result.page.url)}<li role="presentation">
169
166
  <Link id={`documentation-result-${index}`} role="option" tabindex={-1} aria-selected={selected === index} href={result.href} onclick={close} onpointermove={() => { selected = index }}>
170
- <small>{t(result.page.category ?? '')} · {kinds[result.page.kind ?? ''] ?? (result.page.url.includes('/guide') ? 'Guide' : 'Page')} · {reasons?.[result.reason] ?? result.reason}</small>
167
+ <small>{t(result.page.category ?? '')} · {t(kinds[result.page.kind ?? ''] ?? (result.page.url.includes('/guide') ? 'Guide' : 'Page'))} · {t(result.reason)}</small>
171
168
  <b>{result.page.title}</b>
172
169
  <span>{@render excerpt(result.excerpt, query)}</span>
173
170
  </Link>
174
171
  </li>{/each}{/if}
175
172
  </ol>
176
173
  </div>
177
- <div class="ui-dialog-foot" aria-hidden="true"><span><span class="ui-kbd"><IconArrowUp size={12} /></span><span class="ui-kbd"><IconArrowDown size={12} /></span>{tw ? '選擇' : 'Navigate'}</span><span><span class="ui-kbd"><IconCornerDownLeft size={12} /></span>{tw ? '開啟' : 'Open'}</span><span class="ui-search-shortcut">{tw ? '隨時搜尋' : 'Search anytime'}<kbd class="ui-kbd">{shortcutModifier}</kbd><kbd class="ui-kbd">K</kbd></span></div>
174
+ <div class="ui-dialog-foot" aria-hidden="true"><span><span class="ui-kbd"><IconArrowUp size={12} /></span><span class="ui-kbd"><IconArrowDown size={12} /></span>{t('Navigate')}</span><span><span class="ui-kbd"><IconCornerDownLeft size={12} /></span>{t('Open')}</span><span class="ui-search-shortcut">{t('Search anytime')}<kbd class="ui-kbd">{shortcutModifier}</kbd><kbd class="ui-kbd">K</kbd></span></div>
178
175
  </dialog>
179
176
  {/if}
@@ -3,13 +3,13 @@
3
3
  import relativeTime from 'dayjs/plugin/relativeTime'
4
4
  import 'dayjs/locale/zh-tw'
5
5
  import 'dayjs/locale/en'
6
+ import 'dayjs/locale/ja'
7
+ import 'dayjs/locale/ko'
8
+ import 'dayjs/locale/zh-cn'
9
+ import { languageOf, formatDate } from '../common/locales'
6
10
 
7
11
  dayjs.extend(relativeTime)
8
12
 
9
- const locales: Record<string, string> = {
10
- tw: 'zh-tw',
11
- en: 'en'
12
- }
13
13
  </script>
14
14
 
15
15
  <script lang="ts">
@@ -21,8 +21,8 @@
21
21
  const { locale } = getSite()
22
22
  let hydrated = $state(false)
23
23
  const formattedDate = $derived(hydrated
24
- ? dayjs(timestamp).locale(locales[locale]).fromNow()
25
- : dayjs(timestamp).format('ddd, MMMM D YYYY'))
24
+ ? dayjs(timestamp).locale(languageOf(locale).toLowerCase()).fromNow()
25
+ : formatDate(timestamp, locale, { weekday: 'short' }))
26
26
 
27
27
  onMount(() => {
28
28
  hydrated = true
@@ -13,6 +13,6 @@ export function searchLabels(translate: (text: string) => string, locale: string
13
13
  searchPlaceholder,
14
14
  /** The placeholder without its shortcut, which triggers show as a key. */
15
15
  searchText: searchPlaceholder.replace(/\s*⌘\s*K\s*…?$/u, ''),
16
- searchLabel: locale === 'tw' ? '搜尋文件' : 'Search documentation',
16
+ searchLabel: translate('Search documentation'),
17
17
  }
18
18
  }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  import type { AstroComponentFactory } from 'astro/runtime/server/index.js'
3
3
  import type { PageParams } from '../types/Props'
4
- import { resolveContent, type ContentModule } from './create-page'
4
+ import { resolveDocument, resolveNavigation, type ContentModule } from './create-page'
5
5
 
6
6
  interface Props {
7
7
  params: PageParams
@@ -18,18 +18,23 @@ interface Props {
18
18
  }
19
19
 
20
20
  const { params, metadata, dictionaries, categories, content, Layout, noTOC = false, subtitle, ...options } = Astro.props
21
- const contentModule = content ? await resolveContent(params.locale, metadata, content) : undefined
21
+ const document = await resolveDocument(params.locale, metadata, content)
22
+ const contentModule = document.content
23
+ const pageMetadata = document.metadata
24
+ const pageCategories = resolveNavigation(params.locale, categories)
25
+ const locals = Astro.locals as Record<string, any>
26
+ locals.masterDocument = contentModule?.frontmatter ?? {}
22
27
  if (contentModule && !contentModule.default) throw new Error('Invalid content module')
23
28
  const Content = contentModule?.default
24
29
  const toc = contentModule?.toc
25
30
  ---
26
31
 
27
32
  {Layout
28
- ? <Layout {...options} params={params} pageCategories={categories} dictionaries={dictionaries} metadata={metadata} toc={noTOC ? null : toc} subtitle={subtitle}>
29
- {Content && <Content pageCategories={categories} />}
33
+ ? <Layout {...options} params={params} {pageCategories} dictionaries={dictionaries} metadata={pageMetadata} toc={noTOC ? null : toc} subtitle={subtitle}>
34
+ {Content && <Content {pageCategories} />}
30
35
  <slot />
31
36
  </Layout>
32
37
  : <Fragment>
33
- {Content && <Content pageCategories={categories} />}
38
+ {Content && <Content {pageCategories} />}
34
39
  <slot />
35
40
  </Fragment>}
@@ -1,10 +1,14 @@
1
1
  import type { AstroComponentFactory } from 'astro/runtime/server/index.js'
2
2
  import type { Props } from '../types/Props'
3
3
  import generate from '../utils/generate-metadata'
4
+ import config from 'virtual:master-site/config'
5
+ import metadataByPathname from 'virtual:master-site/localized-metadata'
6
+ import { localizedMetadata } from '../utils/localized-metadata'
4
7
 
5
8
  export interface ContentModule {
6
9
  default: AstroComponentFactory
7
10
  toc?: any
11
+ frontmatter?: Record<string, any>
8
12
  }
9
13
 
10
14
  /** Translated MDX lives beside its source as `content.<locale>.mdx`. */
@@ -20,12 +24,35 @@ export function createPageMetadata(metadata: any, dictionaries: Record<string, (
20
24
  * Astro collects a page's stylesheets from its module graph when the page module loads, before a pending dynamic import finishes in dev.
21
25
  */
22
26
  export async function resolveContent(locale: string, metadata: any, content: ContentModule) {
23
- const directory = contentDirectoryOf(metadata)
24
- return (directory ? await translatedContent[`/content/${directory}/content.${locale}.mdx`]?.() : undefined) ?? content
27
+ const propagated = await loadLocalizedContent(locale, metadata)
28
+ if (propagated) return await propagated.getMod()
29
+ if (locale !== config.i18n.defaultLocale) throw new Error(`Missing localized content: ${metadata.pathname ?? metadata.sourcePath} (${locale})`)
30
+ return content
25
31
  }
26
32
 
27
- function contentDirectoryOf(metadata: any) {
28
- const sourcePath = metadata?.sourcePath
29
- if (typeof sourcePath !== 'string') return
30
- return sourcePath.match(/^site\/content\/(.+)\/content\.mdx$/)?.[1]
33
+ /** The configured inventory owns content roots; metadata carries a canonical pathname. */
34
+ export async function loadLocalizedContent(locale: string, metadata: any) {
35
+ const documents = translatedContent[metadata.pathname]
36
+ if (!documents) return
37
+ const load = documents[locale]
38
+ if (!load) throw new Error(`Missing localized content: ${metadata.pathname} (${locale})`)
39
+ return await load()
40
+ }
41
+
42
+ export function resolveMetadata(locale: string, metadata: any) {
43
+ const variants = metadataByPathname[metadata.pathname]
44
+ if (!variants) return { ...metadata, locale }
45
+ const frontmatter = variants[locale]
46
+ if (!frontmatter && locale !== config.i18n.defaultLocale) throw new Error(`Missing localized metadata: ${metadata.pathname} (${locale})`)
47
+ return localizedMetadata(metadata, frontmatter ?? {}, locale)
48
+ }
49
+
50
+ export async function resolveDocument(locale: string, metadata: any, content?: ContentModule) {
51
+ const module = content ? await resolveContent(locale, metadata, content) : await (await loadLocalizedContent(locale, metadata))?.getMod()
52
+ return { content: module, metadata: resolveMetadata(locale, metadata) }
53
+ }
54
+
55
+ /** Localized navigation reads lightweight frontmatter, without importing every MDX page. */
56
+ export function resolveNavigation(locale: string, categories: any[] = []) {
57
+ return categories.map(category => ({ ...category, pages: category.pages.filter((page: any) => !metadataByPathname[page.pathname] || metadataByPathname[page.pathname][locale]).map((page: any) => resolveMetadata(locale, page)) }))
31
58
  }
@@ -2,7 +2,6 @@
2
2
  import '@master/internal-site/styles/docs-shell/prose.css'
3
3
  import '@master/internal-site/styles/blog-materials.css'
4
4
  import '@master/internal-site/styles/blog-article.css'
5
- import translations from 'virtual:master-site/translations'
6
5
  import Footer from '@master/internal-site/components/Footer.svelte'
7
6
  import PageContentBootstrap from '@master/internal-site/components/PageContentBootstrap.astro'
8
7
  import { siteOf } from '@master/internal-site/astro/site-context'
@@ -10,10 +9,6 @@ import BlogArticleMasthead from '@master/internal-site/blog/article/BlogArticleM
10
9
  import BlogArticleContents from '@master/internal-site/blog/article/BlogArticleContents.svelte'
11
10
  import BlogArticleEnd from '@master/internal-site/blog/article/BlogArticleEnd.astro'
12
11
  import { neighborsOf } from '@master/internal-site/blog/article/neighbors'
13
- import { contentLanguageOf } from '@master/internal-site/blog/article/language'
14
-
15
- /** Translated MDX lives beside its source as `content.<locale>.mdx` (see docs-shell/factories/create-page). */
16
- const translatedContent = new Set(Object.keys(translations))
17
12
 
18
13
  /**
19
14
  * A blog post, drawn with the homepage's drafting guides: the header, the reading column (the documentation's measure
@@ -22,11 +17,8 @@ const translatedContent = new Set(Object.keys(translations))
22
17
  * left margin.
23
18
  */
24
19
  const { metadata, toc, pageCategories: posts = [], footerProps = {} } = Astro.props
25
- const { t: $, i18n, locale } = siteOf(Astro)
26
- const { defaultLocale, hreflangOfLocale } = i18n
20
+ const { t: $ } = siteOf(Astro)
27
21
  const { newer, older } = neighborsOf(metadata.sourcePath, posts)
28
- // A post not yet translated is shown in its own language; the body says so, for screen readers and Han type rules.
29
- const proseLanguage = contentLanguageOf(metadata.sourcePath, translatedContent, { locale, defaultLocale, hreflangOfLocale })
30
22
  const contents = (toc ?? []).filter((item: any) => item.level === 2 || item.level === 3).map(({ id, title, level }: any) => ({ id, title, level }))
31
23
  const hasSections = contents.some((item: any) => item.level === 2)
32
24
  // As on the blog index, a category every post shares says nothing about the next one.
@@ -39,9 +31,9 @@ const showCategory = posts.length > 1
39
31
  {/* The post's own header, so the title, date and authors belong to the article. */}
40
32
  <BlogArticleMasthead metadata={metadata} />
41
33
  {/* In the page's outer left margin, beside the text it lists. */}
42
- {hasSections ? <BlogArticleContents client:idle={{ timeout: 500 }} items={contents} label={$('On this page')} lang={proseLanguage} /> : null}
34
+ {hasSections ? <BlogArticleContents client:idle={{ timeout: 500 }} items={contents} label={$('On this page')} /> : null}
43
35
  <div class="blog-article-body blog-rails blog-rails-rule">
44
- <div class="blog-article-prose ui-prose" lang={proseLanguage}>
36
+ <div class="blog-article-prose ui-prose">
45
37
  <slot />
46
38
  </div>
47
39
  </div>
@@ -1,16 +1,15 @@
1
- import Slugger from 'github-slugger'
2
1
  import { visit } from 'unist-util-visit'
3
- import resolveHeading from '../utils/resolve-heading.js'
2
+ import { createHeadingResolver } from '../utils/resolve-heading.js'
4
3
  import { parse } from 'acorn'
5
4
 
6
5
  export default function remarkSlugAndTOC() {
7
6
  return (tree) => {
8
- const slugs = new Slugger()
7
+ const resolveHeading = createHeadingResolver()
9
8
  const toc = []
10
9
 
11
10
  visit(tree, 'heading', (node) => {
12
11
  const depth = node.depth
13
- if (depth !== 2 && depth !== 3) return
12
+ if (depth < 2 || depth > 6) return
14
13
 
15
14
  // Extract raw text from heading children
16
15
  const title = node.children
@@ -19,8 +18,7 @@ export default function remarkSlugAndTOC() {
19
18
  .join(' ')
20
19
  .trim()
21
20
 
22
- const id = slugs.slug(title)
23
- const resolved = resolveHeading(title, id)
21
+ const resolved = resolveHeading(title, depth)
24
22
  // Explicit IDs survive title edits and translations; they are not visible prose.
25
23
  const lastText = node.children.findLast((child) => child.type === 'text')
26
24
  if (lastText) lastText.value = lastText.value.replace(/\s+\{#[\w-]+\}$/, '')
@@ -29,7 +27,7 @@ export default function remarkSlugAndTOC() {
29
27
  node.data.id = resolved.id
30
28
  node.data.hProperties.id = resolved.id
31
29
 
32
- toc.push({
30
+ if (depth === 2 || depth === 3) toc.push({
33
31
  id: resolved.id,
34
32
  title: resolved.title,
35
33
  level: depth
@@ -1,4 +1,7 @@
1
1
  /** Code blocks render as static markup; these delegated handlers switch tabs and copy code. */
2
+ import { readSiteContext } from '../site'
3
+
4
+ const t = (text: string) => readSiteContext().translations[text] ?? text
2
5
 
3
6
  const tabChange = 'site-code-tabs:change'
4
7
  const resetTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>()
@@ -24,7 +27,7 @@ function selectTab(root: HTMLElement, name: string) {
24
27
  const copy = root.querySelector<HTMLButtonElement>('[data-code-copy]')
25
28
  if (copy) {
26
29
  copy.dataset.codeCopy = name
27
- copy.ariaLabel = copy.title = `Copy ${name}`
30
+ copy.ariaLabel = copy.title = t('Copy {name}').replace('{name}', t(name))
28
31
  }
29
32
  }
30
33
 
@@ -55,7 +58,7 @@ function showCopyStatus(button: HTMLButtonElement, status: 'idle' | 'pending' |
55
58
  const element = button.closest('.ui-copy')?.querySelector<HTMLElement>('.ui-copy-status')
56
59
  const result = status === 'copied' ? 'success' : status === 'error' ? 'error' : undefined
57
60
  if (element) {
58
- element.textContent = status === 'copied' ? `${name || 'Code'} copied` : status === 'error' ? 'Clipboard unavailable. Select the code to copy it.' : ''
61
+ element.textContent = status === 'copied' ? t('{name} copied').replace('{name}', t(name || 'Code')) : status === 'error' ? t('Clipboard unavailable. Select the code to copy it.') : ''
59
62
  if (result) element.dataset.result = result
60
63
  else delete element.dataset.result
61
64
  }
@@ -15,6 +15,7 @@ export interface SiteI18n {
15
15
  defaultLocale: string
16
16
  locales: readonly string[]
17
17
  localizablePathnameRoots: string[]
18
+ localizableOrigins?: Record<string, readonly string[]>
18
19
  hreflangOfLocale: Record<string, string>
19
20
  nameOfLocale: Record<string, string>
20
21
  }
@@ -47,6 +48,7 @@ export function createSite(value: SiteContextValue): Site {
47
48
  }
48
49
 
49
50
  let clientSite: Site | undefined
51
+ let clientContext: string | undefined
50
52
 
51
53
  /** Read the context Document.astro serializes for client islands. */
52
54
  export function readSiteContext(): SiteContextValue {
@@ -63,5 +65,11 @@ export function getSite(): Site {
63
65
  const value = getContext<SiteContextValue | undefined>(siteContextKey)
64
66
  if (value) return createSite(value)
65
67
  if (typeof document === 'undefined') throw new Error('getSite() must run inside a component rendered by the site')
66
- return clientSite ??= createSite(readSiteContext())
68
+ const serialized = document.getElementById(siteContextElementId)?.textContent
69
+ if (!serialized) throw new Error(`Missing #${siteContextElementId}`)
70
+ if (serialized !== clientContext) {
71
+ clientContext = serialized
72
+ clientSite = createSite(JSON.parse(serialized))
73
+ }
74
+ return clientSite!
67
75
  }
@@ -0,0 +1,2 @@
1
+ /** Overlay prose fields only; executable source and technical identity stay shared. */
2
+ export declare function demoCopy<T>(frontmatter: Record<string, any>, locale: string, key: string, original: T): T
@@ -0,0 +1,18 @@
1
+ const proseFields = new Set(['title', 'description', 'caption', 'summary', 'label', 'note', 'lead', 'sourceLabel', 'resultLabel', 'alt'])
2
+
3
+ /** Overlay article-owned prose without copying or changing executable examples. */
4
+ export function demoCopy(frontmatter, locale, key, original) {
5
+ if (locale === 'en') return original
6
+ const copy = frontmatter.demos?.[key]
7
+ const walk = (value, translated, field, location) => {
8
+ if (typeof value === 'string') {
9
+ if (!proseFields.has(field) || !value.trim()) return value
10
+ if (typeof translated !== 'string' || !translated.trim()) throw new Error(`Missing localized demo copy: ${key}.${location} (${locale})`)
11
+ return translated
12
+ }
13
+ if (Array.isArray(value)) return value.map((item, index) => walk(item, translated?.[index], field, `${location}[${index}]`))
14
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, walk(item, translated?.[name], name, location ? `${location}.${name}` : name)]))
15
+ return value
16
+ }
17
+ return walk(original, copy, '', '')
18
+ }
@@ -2,6 +2,7 @@ import { defu } from 'defu'
2
2
  import type { Metadata } from '../types/Metadata'
3
3
  import type { Props } from '../types/Props'
4
4
  import { createTranslation } from './i18n'
5
+ import { resolveMetadata } from '../factories/create-page'
5
6
 
6
7
  export default async function generate(
7
8
  metadata: Metadata | Record<string, any>,
@@ -9,6 +10,7 @@ export default async function generate(
9
10
  dictionaries: Record<string, () => Promise<any>>
10
11
  ): Promise<Metadata> {
11
12
  const { locale } = params
13
+ metadata = resolveMetadata(locale, metadata)
12
14
  const $ = await createTranslation(locale, dictionaries)
13
15
  const description = $(metadata.description)
14
16
  const category = $(metadata.category)
@@ -6,6 +6,7 @@ export interface LocalizePathnameOptions {
6
6
  locales: readonly string[]
7
7
  localePrefixMode?: LocalePrefixMode
8
8
  localizablePathnameRoots?: readonly string[]
9
+ localizableOrigins?: Record<string, readonly string[]>
9
10
  }
10
11
 
11
12
  const nonPagePathnameRoots = new Set([
@@ -24,8 +25,18 @@ export function localizePathname(href: string, {
24
25
  locale,
25
26
  locales,
26
27
  localePrefixMode = 'canonical',
27
- localizablePathnameRoots
28
- }: LocalizePathnameOptions) {
28
+ localizablePathnameRoots,
29
+ localizableOrigins
30
+ }: LocalizePathnameOptions): string {
31
+ if (/^https?:\/\//i.test(href)) {
32
+ let url: URL
33
+ try { url = new URL(href) } catch { return href }
34
+ const roots = localizableOrigins?.[url.origin]
35
+ if (!roots) return href
36
+ return url.origin + localizePathname(url.pathname + url.search + url.hash, {
37
+ defaultLocale, locale, locales, localePrefixMode, localizablePathnameRoots: roots
38
+ })
39
+ }
29
40
  if (!href || !href.startsWith('/') || href.startsWith('//')) return href
30
41
 
31
42
  const parsedHref = parseLocalHref(href)
@@ -1,3 +1,5 @@
1
+ import sharedTranslations from '../common/translations'
2
+
1
3
  export async function createTranslation(locale: string, dictionaries: Record<string, () => Promise<any>>): Promise<(text: any) => string> {
2
4
  const translations = await importTranslations(locale, dictionaries)
3
5
  return (text: any) => (translations)[text || ''] || text
@@ -8,5 +10,6 @@ export const importTranslations = async (locale: string, dictionaries: Record<st
8
10
  throw new Error('locale is required')
9
11
  }
10
12
  const load = dictionaries[locale as keyof typeof dictionaries]
11
- return await load()
13
+ if (!load && !sharedTranslations[locale]) throw new Error(`Unsupported locale: ${locale}`)
14
+ return { ...sharedTranslations[locale], ...(load ? await load() : {}) }
12
15
  }