@master/internal-site 0.1.2 → 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 (239) hide show
  1. package/README.md +43 -0
  2. package/dist/astro.mjs +233 -47
  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 +11 -5
  10. package/src/astro/localized-mdx.ts +45 -0
  11. package/src/astro/routes.template.ts +4 -0
  12. package/src/astro/site-context.ts +10 -2
  13. package/src/astro/style-assets.ts +107 -0
  14. package/src/benchmarks/BenchmarkBarRows.astro +3 -2
  15. package/src/benchmarks/BenchmarkBars.astro +1 -1
  16. package/src/benchmarks/BenchmarkChart.astro +5 -1
  17. package/src/benchmarks/BenchmarkChartGroup.astro +1 -1
  18. package/src/benchmarks/BenchmarkDataTable.astro +1 -1
  19. package/src/benchmarks/BenchmarkFigure.astro +2 -1
  20. package/src/benchmarks/BenchmarkMetricTable.astro +2 -2
  21. package/src/benchmarks/BenchmarkMetrics.astro +1 -1
  22. package/src/benchmarks/BenchmarkSampleSummary.astro +1 -1
  23. package/src/benchmarks/BenchmarkScrollRegion.svelte +2 -1
  24. package/src/benchmarks/BenchmarkSource.astro +1 -1
  25. package/src/benchmarks/BenchmarkStackedBars.astro +4 -3
  26. package/src/benchmarks/BenchmarkStatus.astro +3 -1
  27. package/src/benchmarks/localization.ts +22 -0
  28. package/src/benchmarks/types.ts +2 -0
  29. package/src/blog/article/BlogArticleContents.svelte +1 -1
  30. package/src/blog/article/BlogArticleEnd.astro +6 -9
  31. package/src/blog/article/BlogArticleMasthead.astro +17 -9
  32. package/src/config.ts +3 -0
  33. package/src/css/NamespaceUtilityTable.astro +1 -1
  34. package/src/css/ThemeNumberVariableTable.astro +1 -1
  35. package/src/css/foundation-data/specimen-content.ts +2 -2
  36. package/src/css/markdown.ts +64 -14
  37. package/src/demo/Demo.svelte +10 -4
  38. package/src/demo/DemoAsset.astro +6 -3
  39. package/src/demo/DemoAxes.svelte +4 -2
  40. package/src/demo/DemoCatalog.astro +9 -6
  41. package/src/demo/DemoComparison.svelte +3 -0
  42. package/src/demo/DemoContainer.svelte +8 -3
  43. package/src/demo/DemoControls.svelte +5 -1
  44. package/src/demo/DemoCopyButton.svelte +9 -4
  45. package/src/demo/DemoCopyGroup.svelte +6 -2
  46. package/src/demo/DemoExample.astro +3 -1
  47. package/src/demo/DemoFeatureSupport.svelte +5 -1
  48. package/src/demo/DemoGeneratedCSS.svelte +5 -1
  49. package/src/demo/DemoIndex.astro +6 -3
  50. package/src/demo/DemoLegend.svelte +3 -1
  51. package/src/demo/DemoMeasure.svelte +4 -2
  52. package/src/demo/DemoMedia.svelte +3 -1
  53. package/src/demo/DemoMotion.svelte +6 -3
  54. package/src/demo/DemoPaletteGroups.svelte +4 -2
  55. package/src/demo/DemoScrollArea.svelte +2 -0
  56. package/src/demo/DemoStyleComparison.astro +6 -3
  57. package/src/demo/DemoSurface.svelte +2 -0
  58. package/src/demo/DemoThemeComparison.astro +5 -2
  59. package/src/demo/DemoTokenTable.astro +5 -3
  60. package/src/demo/DemoViewTransition.astro +3 -1
  61. package/src/demo/DemoViewport.svelte +18 -11
  62. package/src/demo/DemoWaterfall.astro +4 -2
  63. package/src/demo/ProjectStyleExample.astro +4 -1
  64. package/src/demo/foundations/FoundationDialog.astro +6 -2
  65. package/src/demo/foundations/FoundationMedia.astro +1 -0
  66. package/src/demo/foundations/FoundationShrink.astro +1 -0
  67. package/src/demo/foundations/FoundationTokensView.astro +16 -5
  68. package/src/demo/foundations/Palette.svelte +11 -7
  69. package/src/demo/foundations/dialog-copy.d.ts +1 -0
  70. package/src/demo/foundations/dialog-copy.js +5 -0
  71. package/src/demo/illustration-materials.ts +1 -1
  72. package/src/demo/illustration.css +218 -0
  73. package/src/docs-shell/common/i18n.config.d.ts +1 -1
  74. package/src/docs-shell/common/i18n.config.js +4 -12
  75. package/src/docs-shell/common/locales.d.ts +8 -0
  76. package/src/docs-shell/common/locales.js +18 -0
  77. package/src/docs-shell/common/terminology.json +32 -0
  78. package/src/docs-shell/common/translations.d.ts +3 -0
  79. package/src/docs-shell/common/translations.js +294 -0
  80. package/src/docs-shell/components/ArticleHeader.astro +5 -3
  81. package/src/docs-shell/components/Callout.astro +1 -0
  82. package/src/docs-shell/components/CodeCopyButton.astro +5 -1
  83. package/src/docs-shell/components/CodeTabBar.astro +7 -3
  84. package/src/docs-shell/components/CodeTabs.astro +2 -1
  85. package/src/docs-shell/components/CodeView.astro +1 -1
  86. package/src/docs-shell/components/DocHeader.svelte +13 -17
  87. package/src/docs-shell/components/DocHeading.astro +3 -1
  88. package/src/docs-shell/components/DocMenuButton.svelte +126 -61
  89. package/src/docs-shell/components/DocSidebar.svelte +3 -2
  90. package/src/docs-shell/components/DocVersionSelect.svelte +2 -0
  91. package/src/docs-shell/components/DocumentAPIIndex.astro +1 -1
  92. package/src/docs-shell/components/DocumentChoices.astro +1 -1
  93. package/src/docs-shell/components/DocumentCodeExampleView.astro +3 -1
  94. package/src/docs-shell/components/DocumentCodeTable.astro +4 -2
  95. package/src/docs-shell/components/DocumentCopyButton.svelte +3 -0
  96. package/src/docs-shell/components/DocumentDisclosure.astro +2 -0
  97. package/src/docs-shell/components/DocumentFileTree.astro +3 -0
  98. package/src/docs-shell/components/DocumentFlow.astro +4 -1
  99. package/src/docs-shell/components/DocumentIllustration.astro +8 -12
  100. package/src/docs-shell/components/DocumentKeyList.astro +6 -4
  101. package/src/docs-shell/components/DocumentNamespaceTable.astro +5 -3
  102. package/src/docs-shell/components/DocumentOptionEntry.astro +0 -1
  103. package/src/docs-shell/components/DocumentOptionList.astro +1 -1
  104. package/src/docs-shell/components/DocumentOptions.astro +3 -2
  105. package/src/docs-shell/components/DocumentParameters.astro +1 -1
  106. package/src/docs-shell/components/DocumentPrompt.astro +2 -0
  107. package/src/docs-shell/components/DocumentValueList.astro +1 -1
  108. package/src/docs-shell/components/DocumentationIndex.svelte +8 -7
  109. package/src/docs-shell/components/ExpandContent.svelte +3 -0
  110. package/src/docs-shell/components/ExplorerView.astro +1 -0
  111. package/src/docs-shell/components/GeneratedCSS.astro +4 -1
  112. package/src/docs-shell/components/Header.svelte +1 -1
  113. package/src/docs-shell/components/InlineCode.astro +1 -0
  114. package/src/docs-shell/components/InteractingIndicator.astro +1 -0
  115. package/src/docs-shell/components/LanguageButton.svelte +1 -0
  116. package/src/docs-shell/components/LanguageSelect.svelte +2 -0
  117. package/src/docs-shell/components/Link.svelte +3 -2
  118. package/src/docs-shell/components/NotFound.astro +6 -4
  119. package/src/docs-shell/components/PMAction.astro +1 -0
  120. package/src/docs-shell/components/PageContent.svelte +1 -1
  121. package/src/docs-shell/components/PageContentDisclosure.astro +2 -1
  122. package/src/docs-shell/components/PageContentList.svelte +2 -0
  123. package/src/docs-shell/components/PageNavs.astro +3 -3
  124. package/src/docs-shell/components/SearchButton.svelte +3 -0
  125. package/src/docs-shell/components/SearchDialog.svelte +18 -16
  126. package/src/docs-shell/components/Segments.svelte +2 -0
  127. package/src/docs-shell/components/StepSection.astro +1 -2
  128. package/src/docs-shell/components/Tabs.astro +1 -0
  129. package/src/docs-shell/components/ThemeSelect.svelte +2 -0
  130. package/src/docs-shell/components/TimeAgo.svelte +6 -6
  131. package/src/docs-shell/components/markdown/Table.astro +1 -0
  132. package/src/docs-shell/components/markdown-components.ts +0 -1
  133. package/src/docs-shell/contexts/search.ts +1 -1
  134. package/src/docs-shell/factories/Page.astro +10 -5
  135. package/src/docs-shell/factories/create-page.ts +33 -6
  136. package/src/docs-shell/layouts/BlogArticle.astro +3 -11
  137. package/src/docs-shell/remark/markdown-components.js +28 -7
  138. package/src/docs-shell/remark/slug-and-toc.js +5 -7
  139. package/src/docs-shell/scripts/code.ts +5 -2
  140. package/src/docs-shell/scripts/document.ts +1 -1
  141. package/src/docs-shell/site.ts +9 -1
  142. package/src/docs-shell/types/Metadata.ts +11 -0
  143. package/src/docs-shell/utils/document-copy.d.ts +2 -0
  144. package/src/docs-shell/utils/document-copy.js +18 -0
  145. package/src/docs-shell/utils/generate-metadata.ts +5 -1
  146. package/src/docs-shell/utils/head.ts +5 -2
  147. package/src/docs-shell/utils/i18n-pathname.ts +13 -2
  148. package/src/docs-shell/utils/i18n.ts +4 -1
  149. package/src/docs-shell/utils/localized-metadata.ts +22 -0
  150. package/src/docs-shell/utils/markdown-links.ts +61 -0
  151. package/src/docs-shell/utils/markdown-tree.ts +19 -6
  152. package/src/docs-shell/utils/resolve-heading.d.ts +1 -0
  153. package/src/docs-shell/utils/resolve-heading.js +21 -0
  154. package/src/docs-shell/utils/search-pages.ts +75 -22
  155. package/src/examples/components/ArticleTransitionScene.svelte +4 -0
  156. package/src/examples/layout-system/page.astro +0 -1
  157. package/src/home/HomeBentoFrame.astro +1 -0
  158. package/src/home/HomeBentoGrid.astro +1 -0
  159. package/src/home/bento.css +167 -0
  160. package/src/home/design.css +1 -1
  161. package/src/home/hero.css +5 -0
  162. package/src/home/home.css +4 -576
  163. package/src/home/segmented-control.css +94 -0
  164. package/src/layouts/Document.astro +4 -2
  165. package/src/public/icons/json.svg +1 -0
  166. package/src/scripts/atomic-class-authoring.ts +98 -0
  167. package/src/scripts/localization.ts +239 -0
  168. package/src/scripts/mdx-sample.ts +12 -0
  169. package/src/scripts/static-social.ts +17 -3
  170. package/src/scripts/style-delivery.mjs +120 -0
  171. package/src/sections/blog.astro +1 -1
  172. package/src/sections/guide.astro +4 -3
  173. package/src/styles/blog-article.css +10 -7
  174. package/src/styles/blog-materials.css +13 -0
  175. package/src/styles/demo.css +0 -1
  176. package/src/styles/docs-shell/code.css +5 -61
  177. package/src/styles/docs-shell/documentation-search.css +1 -0
  178. package/src/styles/docs-shell/header.css +2 -73
  179. package/src/styles/docs-shell/menu.css +37 -17
  180. package/src/styles/docs-shell/page-content.css +2 -37
  181. package/src/styles/docs-shell/prose.css +18 -9
  182. package/src/styles/docs-shell/steps.css +2 -39
  183. package/src/styles/document-comparison.css +5 -2
  184. package/src/styles/documentation-illustration.css +1 -9
  185. package/src/styles/documentation-index.css +2 -1
  186. package/src/styles/documents.css +2 -4
  187. package/src/styles/interface.css +5 -3
  188. package/src/styles/system/DESIGN.md +34 -14
  189. package/src/styles/system/browser.css +21 -0
  190. package/src/styles/system/button-feedback.css +13 -0
  191. package/src/styles/system/callout.css +14 -0
  192. package/src/styles/system/chart.css +18 -0
  193. package/src/styles/system/checks.css +18 -0
  194. package/src/styles/system/code-panel.css +82 -0
  195. package/src/styles/system/code-tabs.css +40 -0
  196. package/src/styles/system/code.css +8 -85
  197. package/src/styles/system/compare.css +7 -0
  198. package/src/styles/system/controls.css +10 -109
  199. package/src/styles/system/copy.css +32 -0
  200. package/src/styles/system/data.css +10 -134
  201. package/src/styles/system/dialog.css +16 -0
  202. package/src/styles/system/disclosure.css +19 -0
  203. package/src/styles/system/expand.css +11 -0
  204. package/src/styles/system/fields.css +25 -0
  205. package/src/styles/system/header-core.css +101 -0
  206. package/src/styles/system/hint.css +5 -0
  207. package/src/styles/system/index.css +13 -0
  208. package/src/styles/system/inline-code.css +4 -0
  209. package/src/styles/system/kbd.css +4 -0
  210. package/src/styles/system/logos.css +10 -0
  211. package/src/styles/system/menu.css +14 -0
  212. package/src/styles/system/metrics.css +22 -0
  213. package/src/styles/system/pagehead.css +13 -0
  214. package/src/styles/system/pager.css +13 -0
  215. package/src/styles/system/parameters.css +17 -0
  216. package/src/styles/system/prompt.css +7 -0
  217. package/src/styles/system/prose.css +4 -75
  218. package/src/styles/system/range.css +12 -0
  219. package/src/styles/system/reading.css +44 -0
  220. package/src/styles/system/scroll.css +6 -0
  221. package/src/styles/system/search-trigger.css +10 -0
  222. package/src/styles/system/segmented.css +22 -0
  223. package/src/styles/system/sheet.css +8 -0
  224. package/src/styles/system/shell.css +8 -102
  225. package/src/styles/system/sidebar.css +14 -0
  226. package/src/styles/system/source.css +5 -0
  227. package/src/styles/system/specimens.css +3 -0
  228. package/src/styles/system/stage.css +18 -0
  229. package/src/styles/system/steps.css +51 -0
  230. package/src/styles/system/surfaces.css +8 -104
  231. package/src/styles/system/table.css +23 -0
  232. package/src/styles/system/toc.css +59 -0
  233. package/src/styles/system/tooltip.css +6 -0
  234. package/src/styles/system/tray-code.css +6 -0
  235. package/src/styles/system/tray.css +20 -0
  236. package/src/styles/system/tree.css +15 -0
  237. package/src/styles/system/waterfall.css +14 -0
  238. package/src/virtual.d.ts +5 -1
  239. package/src/blog/article/language.ts +0 -29
@@ -50,8 +50,11 @@ 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
+ {/* client:load islands can hydrate while a streamed body is still parsing.
56
+ Their request-local context must already exist, even with cached CSS/JS. */}
57
+ <script is:inline type="application/json" id={siteContextElementId} set:html={siteContextJSON}></script>
55
58
  {assets.links.map((href) => <link rel="stylesheet" href={href} />)}
56
59
  {assets.styles.map((css) => <style is:inline set:html={css}></style>)}
57
60
  <title>{head.title}</title>
@@ -62,7 +65,6 @@ if (['dark', 'light'].includes(value)) document.documentElement.style.colorSchem
62
65
  <slot />
63
66
  {/* After the content, so persisted code tabs apply in the first pass, before paint. */}
64
67
  {!bare && <script is:inline set:html={`(${codeTabsBootstrap.toString()})();`}></script>}
65
- <script is:inline type="application/json" id={siteContextElementId} set:html={siteContextJSON}></script>
66
68
  <script>
67
69
  import '../docs-shell/scripts/document'
68
70
  import '../docs-shell/scripts/code'
@@ -0,0 +1 @@
1
+ <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.759 3.975h1.783V5.76H5.759v4.458A1.783 1.783 0 0 1 3.975 12a1.783 1.783 0 0 1 1.784 1.783v4.459h1.783v1.783H5.759c-.954-.24-1.784-.803-1.784-1.783v-3.567a1.783 1.783 0 0 0-1.783-1.783H1.3v-1.783h.892a1.783 1.783 0 0 0 1.783-1.784V5.76A1.783 1.783 0 0 1 5.76 3.975m12.483 0a1.783 1.783 0 0 1 1.783 1.784v3.566a1.783 1.783 0 0 0 1.783 1.784h.892v1.783h-.892a1.783 1.783 0 0 0-1.783 1.783v3.567a1.783 1.783 0 0 1-1.783 1.783h-1.784v-1.783h1.784v-4.459A1.783 1.783 0 0 1 20.025 12a1.783 1.783 0 0 1-1.783-1.783V5.759h-1.784V3.975h1.784M12 14.675a.892.892 0 0 1 .892.892.892.892 0 0 1-.892.892.892.892 0 0 1-.891-.892.892.892 0 0 1 .891-.892m-3.566 0a.892.892 0 0 1 .891.892.892.892 0 0 1-.891.892.892.892 0 0 1-.892-.892.892.892 0 0 1 .892-.892m7.133 0a.892.892 0 0 1 .891.892.892.892 0 0 1-.891.892.892.892 0 0 1-.892-.892.892.892 0 0 1 .892-.892z" fill="#fbc02d"/></svg>
@@ -0,0 +1,98 @@
1
+ /** Repository authoring policy only. Native CSS and runtime parsing are unchanged. */
2
+ import { createToolingSessionSync } from '@master/css-tooling/node'
3
+ import type { MasterCSSManifest } from '@master/css-schema/manifest'
4
+ import preset from '@master/css-preset/default-manifest.json' with { type: 'json' }
5
+
6
+ export const compositeClassProperties = new Set(`all animation animation-range background border border-block border-block-end border-block-start border-bottom border-image border-inline border-inline-end border-inline-start border-left border-right border-top column-rule columns container flex flex-flow font font-synthesis font-variant grid grid-template line-clamp list-style mask mask-border offset outline scroll-timeline text-decoration text-emphasis text-wrap transition view-timeline white-space -webkit-text-stroke -webkit-mask -webkit-mask-box-image`.split(' '))
7
+ const axisProperties = new Set(`background-position background-repeat border-block-color border-block-style border-block-width border-color border-inline-color border-inline-style border-inline-width border-radius border-style border-width contain-intrinsic-size gap grid-area grid-column grid-row inset inset-block inset-inline margin margin-block margin-inline mask-position mask-repeat overflow overscroll-behavior padding padding-block padding-inline place-content place-items place-self scroll-margin scroll-margin-block scroll-margin-inline scroll-padding scroll-padding-block scroll-padding-inline -webkit-mask-position -webkit-mask-repeat`.split(' '))
8
+
9
+ export function atomicClassIssue(className: string): string | undefined {
10
+ const match = /^\(([a-z-]+):(.+)$/.exec(className)
11
+ if (!match) return
12
+ const [, property, tail] = match
13
+ if (!compositeClassProperties.has(property) && !axisProperties.has(property)) return
14
+ let depth = 0
15
+ let value = ''
16
+ let quote = ''
17
+ // The value ends at the closing parenthesis of the `(property:value)` head.
18
+ for (let index = 0; index < tail.length; index++) {
19
+ const char = tail[index]
20
+ if (char === '\\') { value += char + (tail[++index] ?? ''); continue }
21
+ if (quote) { if (char === quote) quote = ''; value += char; continue }
22
+ if (char === '"' || char === "'") { quote = char; value += char; continue }
23
+ if (char === '(') depth++
24
+ if (char === ')' && !depth--) break
25
+ value += !depth && (char === '|' || char === '/' || char === ',') ? '\0' : char
26
+ }
27
+ // A single grid line, named area, or span is one placement setting.
28
+ if (property.startsWith('grid-') && !tail.includes('/')) return
29
+ const parts = value.split('\0')
30
+ if (compositeClassProperties.has(property) && parts.length > 1) return `Express independent settings of ${property} separately`
31
+ if (new Set(parts).size > 1) return `Express different edges or axes of ${property} independently`
32
+ }
33
+
34
+ export function createAtomicClassAudit() {
35
+ const session = createToolingSessionSync({ manifest: preset as unknown as MasterCSSManifest })
36
+ return {
37
+ inspect(source: string, languageId: string) {
38
+ const positions = session.analyzeLintDocument(source, languageId).classPositions
39
+ const issues = positions.flatMap(position => {
40
+ const message = atomicClassIssue(position.token)
41
+ return message ? [{ ...position, message }] : []
42
+ })
43
+ const contexts = new Map<string, typeof positions[number][]>()
44
+ for (const position of positions) {
45
+ const key = `${position.contextRange.start}:${position.contextRange.end}`
46
+ const group = contexts.get(key) ?? []
47
+ group.push(position)
48
+ contexts.set(key, group)
49
+ }
50
+ for (const group of contexts.values()) {
51
+ for (const conflict of session.lintClassNames(group.map(item => item.token)).partialConflicts) {
52
+ const position = group.find(item => item.token === conflict.className)!
53
+ issues.push({ ...position, message: `Overlapping class ${conflict.conflict}; express each edge independently` })
54
+ }
55
+ }
56
+ return issues
57
+ },
58
+ dispose: () => session.dispose()
59
+ }
60
+ }
61
+
62
+ /** Inventory is explicit so adding a framework or relocating content cannot silently shrink coverage. */
63
+ export async function auditAuthoringRoots(roots: { name: string, directory: string, patterns?: string[] }[]) {
64
+ const { default: glob } = await import('fast-glob')
65
+ const { readFile } = await import('node:fs/promises')
66
+ const { resolve, extname } = await import('node:path')
67
+ const audit = createAtomicClassAudit()
68
+ const inventory: string[] = []
69
+ const failures: { file: string, line: number, token: string, message: string }[] = []
70
+ try {
71
+ for (const root of roots) {
72
+ const files = await glob(root.patterns ?? ['**/*.{astro,svelte,mdx,html,ts,tsx,js,jsx,vue}'], {
73
+ cwd: root.directory,
74
+ ignore: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/.astro/**', '**/.cache/**', '**/*.test.*', '**/*.spec.*', '**/tests/**']
75
+ })
76
+ if (!files.length) throw new Error(`Empty authoring audit root: ${root.name}`)
77
+ for (const file of files.sort()) {
78
+ const name = `${root.name}/${file}`
79
+ inventory.push(name)
80
+ const source = await readFile(resolve(root.directory, file), 'utf8')
81
+ const extension = extname(file)
82
+ const language = ({ '.astro': 'astro', '.svelte': 'svelte', '.mdx': 'mdx', '.vue': 'vue' } as Record<string, string>)[extension] ?? (/\.[jt]sx?$/.test(file) ? 'typescriptreact' : 'html')
83
+ const add = (text: string, languageId: string, offset = 0) => {
84
+ for (const issue of audit.inspect(text, languageId)) {
85
+ failures.push({ file: name, line: source.slice(0, offset + issue.range.start).split('\n').length, token: issue.token, message: issue.message })
86
+ }
87
+ }
88
+ add(source, language)
89
+ // Preserve the existing policy's escaped HTML generator coverage.
90
+ if (language === 'typescriptreact') for (const match of source.matchAll(/"(?:\\.|[^"\\])*"/g)) {
91
+ if (!match[0].includes('class=\\"')) continue
92
+ add(JSON.parse(match[0]), 'html', match.index)
93
+ }
94
+ }
95
+ }
96
+ } finally { audit.dispose() }
97
+ return { inventory, failures }
98
+ }
@@ -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
+ }
@@ -15,7 +15,7 @@ export interface StaticSocialOptions {
15
15
  locales?: readonly string[]
16
16
  }
17
17
 
18
- /** Replace dynamic card placeholders with static, content-addressed PNG files. */
18
+ /** Share the canonical source artwork across locales; social text remains localized. */
19
19
  export async function generateStaticSocialImages({ siteDir, outDir, origin, logo, defaultLocale = 'en', locales = ['en'] }: StaticSocialOptions) {
20
20
  const authorImageFiles: Record<string, string> = { Aron: 'aron.jpg', Joy: 'joy.jpg', BenSeage: 'benseage.jpg', Miles: 'miles.jpg', Lola: 'lola.jpg' }
21
21
  await generate()
@@ -34,15 +34,29 @@ async function generate() {
34
34
 
35
35
  if (!filesWithMatches.length) return
36
36
 
37
+ const byPath = new Map(filesWithMatches.map(record => [path.relative(outDir, record.file).split(path.sep).join('/'), record]))
38
+ const sourceMatches = (file: string, matches: string[]) => {
39
+ const relative = path.relative(outDir, file).split(path.sep).join('/')
40
+ const [first, ...rest] = relative.split('/')
41
+ const locale = first.replace(/\.html$/, '')
42
+ if (!locales.includes(locale) || locale === defaultLocale) return matches
43
+ const source = byPath.get(rest.join('/') || 'index.html')
44
+ if (!source) throw new Error(`Missing canonical source social image for ${relative}`)
45
+ return source.matches
46
+ }
47
+
37
48
  await rm(path.join(outDir, 'og'), { recursive: true, force: true })
38
49
 
39
50
  const generated = new Map<string, string>()
40
51
 
41
52
  for (const { file, matches, content: originalContent } of filesWithMatches) {
42
53
  let content = originalContent
54
+ const canonical = sourceMatches(file, matches)
43
55
 
44
- for (const rawUrl of matches) {
45
- const normalizedUrl = normalizeOgImageUrl(rawUrl)
56
+ for (const [index, rawUrl] of matches.entries()) {
57
+ const sourceUrl = canonical[index] ?? (canonical.length === 1 ? canonical[0] : undefined)
58
+ if (!sourceUrl) throw new Error(`Cannot match canonical social artwork for ${path.relative(outDir, file)}`)
59
+ const normalizedUrl = normalizeOgImageUrl(sourceUrl)
46
60
  const cacheKey = getOgImageCacheKey(normalizedUrl)
47
61
  let staticPath = generated.get(cacheKey)
48
62
  if (!staticPath) {
@@ -0,0 +1,120 @@
1
+ /** Audit the built CSS graph. Bytes are asset sizes, not browser transfer estimates. */
2
+ import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
3
+ import { resolve, relative, dirname } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { createHash } from 'node:crypto'
6
+ import { gzipSync, brotliCompressSync } from 'node:zlib'
7
+ import { locales as siteLocales, defaultLocale as siteDefaultLocale } from '../docs-shell/common/locales.js'
8
+
9
+ export async function filesAt(root) {
10
+ const files = []
11
+ for (const entry of await readdir(root, { withFileTypes: true })) {
12
+ const file = resolve(root, entry.name)
13
+ if (entry.isDirectory()) files.push(...await filesAt(file))
14
+ else files.push(file)
15
+ }
16
+ return files.sort()
17
+ }
18
+ const hash = text => createHash('sha256').update(text).digest('hex')
19
+ const sizes = text => ({ bytes: Buffer.byteLength(text), gzip: gzipSync(text).length, brotli: brotliCompressSync(text).length })
20
+ const attributes = text => Object.fromEntries([...text.matchAll(/([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)].map(m => [m[1], m[2] ?? m[3]]))
21
+ export const importsOf = css => [...css.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/@import\s+(?:url\(\s*)?["']([^"']+)["']/g)].map(m => m[1])
22
+ const families = ['sidebar', 'toc', 'pager', 'tree', 'tray', 'switch', 'range', 'chart', 'code', 'table']
23
+
24
+ export async function auditStyles(directory) {
25
+ const root = resolve(directory), assets = {}, routes = {}
26
+ const origin = 'https://style-audit.invalid'
27
+ async function asset(href) {
28
+ if (assets[href]) return assets[href]
29
+ const file = resolve(root, '.' + decodeURIComponent(href))
30
+ if (!file.startsWith(root + '/')) throw new Error(`CSS escapes output: ${href}`)
31
+ const css = await readFile(file, 'utf8')
32
+ return assets[href] = { ...sizes(css), sha256: hash(css), imports: importsOf(css), css }
33
+ }
34
+ for (const file of await filesAt(root)) {
35
+ if (!file.endsWith('.html')) continue
36
+ const html = await readFile(file, 'utf8'), seen = new Set()
37
+ const pathname = '/' + relative(root, file).replaceAll('\\', '/')
38
+ const route = pathname === '/index.html' ? '/' : pathname.replace(/(?:\/index)?\.html$/, '')
39
+ let depth = 0
40
+ async function walk(href, parent, level, ancestors = []) {
41
+ const url = new URL(href, origin + parent)
42
+ if (url.origin !== origin || url.protocol !== 'https:') return
43
+ const key = url.pathname
44
+ if (ancestors.includes(key)) throw new Error(`Cyclic CSS import: ${[...ancestors, key].join(' -> ')}`)
45
+ depth = Math.max(depth, level)
46
+ const item = await asset(key)
47
+ seen.add(key)
48
+ for (const dep of item.imports) await walk(dep, key, level + 1, [...ancestors, key])
49
+ }
50
+ const links = [...html.matchAll(/<link\b([^>]+)>/g)].map(m => attributes(m[1])).filter(a => a.rel === 'stylesheet').map(a => a.href)
51
+ for (const href of links) await walk(href, pathname, 1)
52
+ const inline = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/g)].map(m => m[1])
53
+ for (const css of inline) for (const href of importsOf(css)) await walk(href, pathname, 1)
54
+ const texts = [...seen].map(href => assets[href].css)
55
+ const delivered = [...texts, ...inline].join('\n')
56
+ const totals = [...seen].map(href => assets[href]).concat(inline.map(sizes))
57
+ routes[route] = {
58
+ links, assets: [...seen], files: seen.size, depth,
59
+ ...Object.fromEntries(['bytes', 'gzip', 'brotli'].map(k => [k, totals.reduce((n, x) => n + x[k], 0)])),
60
+ inlineSHA256: hash(inline.join('\n')),
61
+ inlineBytes: inline.reduce((n, s) => n + Buffer.byteLength(s), 0),
62
+ families: Object.fromEntries(families.map(name => [name, new RegExp(`(?:^|[;{}])\\s*\\.ui-${name}\\s*\\{`).test(delivered)])),
63
+ foundationCount: [...delivered.matchAll(/body,\s*:host\s*\{\s*font-family:\s*var\(--font-family-sans\)/g)].length,
64
+ uncompiled: /(?:^|[;{}])\s*@(apply|compose|reference|prune|light|dark|variant|theme|mixin|utility|master|source|safelist|blocklist)\b/.test(delivered),
65
+ layerOrder: [...new Set([...delivered.matchAll(/@layer\s+([\w-]+(?:\s*,\s*[\w-]+)*)\s*[;{]/g)].flatMap(m => m[1].split(/\s*,\s*/)))],
66
+ }
67
+ }
68
+ return { version: 1, routes, assets: Object.fromEntries(Object.entries(assets).map(([href, { css, ...item }]) => [href, item])) }
69
+ }
70
+
71
+ /** Exact per-page ceilings; localized editions share the ceiling only with identical CSS. */
72
+ export function assertStyleBudget(report, budget, { locales = siteLocales, defaultLocale = siteDefaultLocale } = {}) {
73
+ const failures = []
74
+ if (!Object.keys(report.routes).length) throw new Error('Empty style delivery inventory')
75
+ for (const [route, item] of Object.entries(report.routes)) {
76
+ const segment = route.split('/')[1]
77
+ const canonical = segment !== defaultLocale && locales.includes(segment) ? route.slice(segment.length + 1) || '/' : route
78
+ const cap = budget.routes[route] ?? budget.routes[canonical]
79
+ if (!cap) { failures.push(`${route}: missing reviewed budget`); continue }
80
+ for (const key of ['files', 'depth', 'gzip', 'brotli', 'inlineBytes']) {
81
+ if (item[key] > cap[key]) failures.push(`${route}: ${key} ${item[key]} > ${cap[key]}`)
82
+ }
83
+ if (item.foundationCount !== 1) failures.push(`${route}: foundation count ${item.foundationCount}`)
84
+ if (item.layerOrder.join(',') !== 'theme,base,defaults,components,utilities') failures.push(`${route}: layer order`)
85
+ if (item.uncompiled) failures.push(`${route}: uncompiled directives`)
86
+ if (canonical !== route) {
87
+ const source = report.routes[canonical]
88
+ const assets = entry => entry.assets.map(href => report.assets[href].sha256).sort().join(',')
89
+ if (!source) failures.push(`${route}: missing canonical style route ${canonical}`)
90
+ else if (assets(item) !== assets(source) || item.inlineSHA256 !== source.inlineSHA256) failures.push(`${route}: CSS differs from ${canonical}`)
91
+ }
92
+ if (['/', '/404'].includes(canonical)) {
93
+ for (const name of ['sidebar', 'toc', 'pager', 'switch', 'range']) if (item.families[name]) failures.push(`${route}: unrelated ${name}`)
94
+ }
95
+ }
96
+ for (const route of Object.keys(budget.routes)) if (!report.routes[route]) failures.push(`${route}: missing route`)
97
+ if (failures.length) throw new Error(failures.join('\n'))
98
+ }
99
+
100
+ export function compareStyles(before, after) {
101
+ return Object.entries(after.routes).map(([route, b]) => {
102
+ const a = before.routes[route]
103
+ return { route, ...(a ? Object.fromEntries(['files', 'depth', 'bytes', 'gzip', 'brotli'].map(k => [k, b[k] - a[k]])) : { added: true }) }
104
+ })
105
+ }
106
+
107
+ export async function runStyleAudit(args) {
108
+ const [root, output, comparison, cap] = args
109
+ const baseline = comparison === '--budget' ? undefined : comparison
110
+ const budget = cap
111
+ if (!root || !output) throw new Error('Usage: node style-delivery.mjs <built-output> <report.json> [baseline.json]')
112
+ const report = await auditStyles(root)
113
+ await mkdir(dirname(resolve(output)), { recursive: true })
114
+ await writeFile(output, JSON.stringify(report, null, 2) + '\n')
115
+ if (budget) assertStyleBudget(report, JSON.parse(await readFile(budget, 'utf8')))
116
+ if (baseline) console.log(JSON.stringify(compareStyles(JSON.parse(await readFile(baseline, 'utf8')), report), null, 2))
117
+ else console.log(`${Object.keys(report.routes).length} routes, ${Object.keys(report.assets).length} CSS assets audited`)
118
+ }
119
+
120
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await runStyleAudit(process.argv.slice(2))
@@ -2,7 +2,7 @@
2
2
  import DocumentShell from './document.astro'
3
3
  import DocHeader from '@master/internal-site/components/DocHeader.svelte'
4
4
 
5
- export const bodyClass = '(background-repeat:no-repeat) (background-image:linear-gradient(oklch(100%|0|none),var(--color-zinc-0)|100vh,var(--color-zinc-0))) (background-size:cover) (background-image:linear-gradient(var(--color-zinc-90),var(--color-zinc-95)|100vh,var(--color-zinc-95)))@apply(--site-dark)'
5
+ export const bodyClass = 'blog-page'
6
6
  ---
7
7
 
8
8
  <DocumentShell>
@@ -4,7 +4,9 @@ import DocHeader from '@master/internal-site/components/DocHeader.svelte'
4
4
  import DocSidebar from '@master/internal-site/components/DocSidebar.svelte'
5
5
  import DocWrapper from '@master/internal-site/components/DocWrapper.astro'
6
6
  import navigation from 'virtual:master-site/navigation'
7
- const pageCategories = navigation.guide ?? []
7
+ import { resolveNavigation } from '../docs-shell/factories/create-page'
8
+ import { siteOf } from '../astro/site-context'
9
+ const pageCategories = resolveNavigation(siteOf(Astro).locale, navigation.guide ?? [])
8
10
  import type { MetadataTitle } from '@master/internal-site/types/Metadata'
9
11
 
10
12
  export const bodyClass = 'bg-surface-base'
@@ -14,11 +16,10 @@ const sidebarCategories = pageCategories.map(({ name, pages }) => ({
14
16
  name,
15
17
  pages: pages.map(({ title, pathname, other }: { title: MetadataTitle, pathname: string, other?: { subject: string } }) => other ? { title, pathname, other } : { title, pathname })
16
18
  }))
17
- const menuCategories = sidebarCategories.map(category => ({ ...category, pages: category.pages.filter(page => page.pathname.split('/').length === 3) }))
18
19
  ---
19
20
 
20
21
  <DocumentShell>
21
- <DocHeader client:load contained documentNavigation={menuCategories} documentNavigationTitle="Guide" />
22
+ <DocHeader client:load contained />
22
23
  <DocWrapper>
23
24
  <DocSidebar client:load pageCategories={sidebarCategories} />
24
25
  <slot />
@@ -91,6 +91,16 @@
91
91
 
92
92
  /* ───────────────────────── Masthead ───────────────────────── */
93
93
 
94
+ .blog-article-cover {
95
+ display: block;
96
+ width: 100%;
97
+ height: auto;
98
+ margin-top: 2rem;
99
+ border-radius: var(--ui-radius-xl);
100
+ outline: 1px solid var(--ui-line-contour);
101
+ outline-offset: -1px;
102
+ }
103
+
94
104
  .blog-article-masthead {
95
105
  position: relative;
96
106
  min-width: 0;
@@ -650,13 +660,6 @@
650
660
  }
651
661
  }
652
662
 
653
- /* The page ground runs under the overscroll and behind the footer. */
654
- :root:has(.blog-article-page),
655
- :root:has(.blog-article-page) > body {
656
- background-color: light-dark(var(--color-zinc-0), var(--color-zinc-95));
657
- background-image: none;
658
- }
659
-
660
663
  /* Paper is white whatever the screen's scheme: every token resolves its light value. */
661
664
  @media print {
662
665
  :root:has(.blog-article-page) {
@@ -40,6 +40,19 @@
40
40
  * Accessibility: reduced motion drops transitions; more contrast lifts quiet text, contours and guides; forced
41
41
  * colors and print use Canvas / CanvasText and drop the decorative guides.
42
42
  */
43
+ /* The canvas continues through the footer and overscroll in either theme. */
44
+ :root:has(body.blog-page),
45
+ body.blog-page {
46
+ background-color: light-dark(var(--color-zinc-0), var(--color-zinc-95));
47
+ background-image: none;
48
+ }
49
+
50
+ @media print {
51
+ :root:has(body.blog-page) {
52
+ color-scheme: light;
53
+ }
54
+ }
55
+
43
56
  @layer components {
44
57
  .blog-materials {
45
58
  /* The page's own ground, the same the footer stands on, so the index never sits in a tray. */
@@ -7,7 +7,6 @@
7
7
  .ui-tray > .ui-disclosure > :not(summary) { margin-inline: calc(var(--ui-space-sm) * -1); }
8
8
  /* The panel's own padding is part of the summary's inset: its chevron starts where the code lines start. */
9
9
  .ui-tray > details.ui-disclosure > summary { padding-inline: calc(var(--ui-space-md) - var(--ui-space-sm)); }
10
- .ui-tray > .ui-disclosure .ui-code { border-radius: 0 0 var(--ui-radius-xl) var(--ui-radius-xl); background: transparent; box-shadow: inset 0 1px 0 var(--ui-line-hair); }
11
10
 
12
11
  /* A copy group's instruction is a caption over its swatches, not a button's status: one start-aligned sans line. */
13
12
  p.ui-copy > .ui-copy-status { max-width: none; color: var(--ui-text-muted); font: var(--ui-weight-regular) var(--ui-text-caption)/1.5 var(--font-family-sans); text-align: start; }