@master/internal-site 0.2.3 → 0.2.5

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 (31) hide show
  1. package/dist/astro.mjs +1868 -8
  2. package/dist/{localization-LfIbIysE.mjs → localization-IeZXkrNw.mjs} +11 -2
  3. package/dist/localization.mjs +1 -1
  4. package/package.json +3 -2
  5. package/src/astro/image-variants.ts +96 -0
  6. package/src/astro/index.ts +14 -4
  7. package/src/astro/island-scripts.ts +31 -0
  8. package/src/astro/site-translations.ts +31 -0
  9. package/src/astro/style-assets.ts +2 -2
  10. package/src/astro/svelte-client.ts +13 -0
  11. package/src/docs-shell/components/Image.svelte +7 -4
  12. package/src/docs-shell/components/SearchButton.svelte +5 -3
  13. package/src/docs-shell/components/SearchDialog.svelte +47 -161
  14. package/src/docs-shell/components/SearchDialogPanel.svelte +182 -0
  15. package/src/docs-shell/contexts/search.ts +8 -0
  16. package/src/docs-shell/contexts/theme-mode.svelte.ts +6 -1
  17. package/src/docs-shell/scripts/code.ts +9 -4
  18. package/src/docs-shell/site-dictionary.ts +36 -0
  19. package/src/docs-shell/types/StaticImage.ts +2 -0
  20. package/src/examples/components/ArticleTransitionScene.svelte +16 -14
  21. package/src/home/home-lamp.ts +35 -13
  22. package/src/home/icons/artwork/render-prerender-dark.webp +0 -0
  23. package/src/home/icons/artwork/render-prerender-light.webp +0 -0
  24. package/src/home/icons/artwork/source.json +6 -0
  25. package/src/home/icons/artwork.ts +3 -0
  26. package/src/home/icons/catalog.ts +9 -1
  27. package/src/home/icons/render-prerender.svg +12 -0
  28. package/src/layouts/Document.astro +28 -11
  29. package/src/scripts/localization.ts +13 -4
  30. package/src/styles/blog-article.css +5 -4
  31. package/src/styles/system/DESIGN.md +1 -1
@@ -255,6 +255,15 @@ async function sharedInterfaceKeys(sourceRoot = join(dirname(fileURLToPath(impor
255
255
  await visit(sourceRoot);
256
256
  return [...keys].sort();
257
257
  }
258
+ /**
259
+ * Shared copy and data modules can hold reader-facing prose that a translation mirrors.
260
+ * Shared components and compiled tooling cannot: their interface strings are reviewed
261
+ * through the localized dictionaries, so a rendering change must not mark every
262
+ * document that uses them as changed English source.
263
+ */
264
+ function isSharedSourceModule(path) {
265
+ return !/^dist\//.test(path) && !/\.(?:astro|svelte)$/.test(path) && !/\/common\/(?:translations|locales)\.[jt]s$/.test(path);
266
+ }
258
267
  /** Follow local prose/data modules without executing them or importing product compiler code. */
259
268
  async function collectSourceDependencies(files, { siteRoot }) {
260
269
  const root = resolve(siteRoot);
@@ -264,13 +273,13 @@ async function collectSourceDependencies(files, { siteRoot }) {
264
273
  const visit = async (file) => {
265
274
  file = resolve(file).replace(/\.(tw|ja|ko|cn)\.mdx$/, ".mdx");
266
275
  if (!file.startsWith(root + sep) && !file.startsWith(sharedRoot + sep) || seen.has(file)) return;
267
- if (file.startsWith(sharedRoot + sep) && /\/common\/(?:translations|locales)\.[jt]s$/.test(file.replaceAll("\\", "/"))) return;
276
+ if (file.startsWith(sharedRoot + sep) && !isSharedSourceModule(relative(sharedRoot, file).replaceAll("\\", "/"))) return;
268
277
  seen.add(file);
269
278
  const source = await readFile(file, "utf8").catch(() => void 0);
270
279
  if (source === void 0) return;
271
280
  const key = file.startsWith(sharedRoot + sep) ? `@master/internal-site/${relative(sharedRoot, file)}` : relative(root, file);
272
281
  collected[key.replaceAll("\\", "/")] = source;
273
- const imports = source.matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)(['"])([^'"\n]+)\1/g);
282
+ const imports = source.replace(/\b(?:import|export)\s+type\b[^'";]*?\bfrom\s*(['"])[^'"\n]+\1/g, "").matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)(['"])([^'"\n]+)\1/g);
274
283
  for (const [, , specifier] of imports) {
275
284
  if (!specifier.startsWith(".") && !specifier.startsWith("~/site/") && !specifier.startsWith("@master/internal-site/")) continue;
276
285
  let target;
@@ -1 +1 @@
1
- export { t as assertTranslations, n as auditTranslations, r as collectSourceDependencies, i as discoverDocumentResources, a as emptyReviews, o as headingAnchors, p as localizedMetadata, s as readLocalizedDocument, c as reviewTranslation, l as sharedInterfaceKeys, u as stabilizeHeadingAnchors, d as validateSiteTranslations } from "./localization-LfIbIysE.mjs";
1
+ export { t as assertTranslations, n as auditTranslations, r as collectSourceDependencies, i as discoverDocumentResources, a as emptyReviews, o as headingAnchors, p as localizedMetadata, s as readLocalizedDocument, c as reviewTranslation, l as sharedInterfaceKeys, u as stabilizeHeadingAnchors, d as validateSiteTranslations } from "./localization-IeZXkrNw.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@master/internal-site",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "description": "First-party Astro and Svelte website framework for Master sites.",
6
6
  "engines": {
@@ -309,7 +309,7 @@
309
309
  "astro": "7.4.2",
310
310
  "svelte": "5.57.2",
311
311
  "@master/css": "0.0.0 || >=0.0.0-site.1793646900.gdf48b35846c1 <0.0.1 || ^2.0.0",
312
- "@master/css-astro": "0.0.0 || >=0.0.0-site.1793646900.gdf48b35846c1 <0.0.1 || ^2.0.0",
312
+ "@master/css-astro": "^2.0.1",
313
313
  "vite": "8.3.5",
314
314
  "@master/css-compiler": "0.0.0 || >=0.0.0-site.1793646900.gdf48b35846c1 <0.0.1 || ^2.0.0",
315
315
  "@master/css-tooling": "0.0.0 || >=0.0.0-site.1793646900.gdf48b35846c1 <0.0.1 || ^2.0.0",
@@ -349,6 +349,7 @@
349
349
  "@astrojs/markdown-remark": "^7.3.4",
350
350
  "@resvg/resvg-js": "2.6.2",
351
351
  "satori": "0.63.2",
352
+ "sharp": "0.35.5",
352
353
  "mdast-util-mdx-jsx": "^3.2.0",
353
354
  "micromark-extension-mdx-jsx": "^3.0.2"
354
355
  },
@@ -0,0 +1,96 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { basename, extname, join } from 'node:path'
4
+ import sharp from 'sharp'
5
+ import type { Plugin } from 'vite'
6
+ import { cleanId } from './module-id'
7
+
8
+ /** Widths a bitmap is also delivered at, when they are clearly smaller than its own. */
9
+ export const variantWidths = [256, 640, 960, 1280, 1920, 2560]
10
+ export const avifOptions = { quality: 60, effort: 3 } as const
11
+ // libaom splits its work by the configured thread count, so the encoded bytes depend on it.
12
+ // A fixed count keeps every builder's output identical regardless of its CPU count.
13
+ const encoderThreads = 4
14
+ const bitmap = /\.(?:png|jpe?g|webp)$/i
15
+ const marker = '?master-image'
16
+
17
+ export interface ImageVariants {
18
+ /** Files to write into the build output, keyed by their output-relative path. */
19
+ readonly files: Map<string, Uint8Array>
20
+ plugin(): Plugin
21
+ write(outDir: string): Promise<void>
22
+ }
23
+
24
+ /** Delivered widths for a bitmap of the given intrinsic width, smallest first. */
25
+ export function deliveredWidths(width: number) {
26
+ return [...variantWidths.filter(candidate => candidate <= width * 0.8), width]
27
+ }
28
+
29
+ /**
30
+ * Build-time responsive images for bitmap imports.
31
+ *
32
+ * An imported PNG, JPEG or WebP resolves to `{ src, width, height, srcset }`, where every
33
+ * candidate is an AVIF encoded during the build. The module is plain data, so the server
34
+ * render and a hydrating island read the same `srcset`. Encoding is deterministic for a
35
+ * given sharp version; outputs are content-hashed under the assets directory.
36
+ * Development keeps Astro's own image modules so the dev server starts without encoding.
37
+ */
38
+ export function imageVariants({ base, assetsDir }: { base: string; assetsDir: string }): ImageVariants {
39
+ const files = new Map<string, Uint8Array>()
40
+ const modules = new Map<string, Promise<string | undefined>>()
41
+ const directory = assetsDir.replace(/^\/|\/$/g, '')
42
+ const root = base.replace(/\/?$/, '/')
43
+
44
+ async function encode(source: Buffer, stem: string, width: number, intrinsic: number) {
45
+ const pipeline = sharp(source)
46
+ if (width !== intrinsic) pipeline.resize({ width })
47
+ const bytes = await pipeline.avif(avifOptions).toBuffer()
48
+ const hash = createHash('sha256').update(bytes).digest('base64url').slice(0, 8)
49
+ const name = `${directory}/${stem}-${width}.${hash}.avif`
50
+ files.set(name, bytes)
51
+ return { url: root + name, width }
52
+ }
53
+
54
+ async function load(file: string) {
55
+ const source = await readFile(file)
56
+ const { width, height, pages } = await sharp(source).metadata()
57
+ // Animated sources keep their original file.
58
+ if (!width || !height || (pages ?? 1) > 1) return
59
+ const stem = basename(file, extname(file)).replace(/[^\w-]+/g, '-')
60
+ const variants = await Promise.all(deliveredWidths(width).map(size => encode(source, stem, size, width)))
61
+ const image = { src: variants.at(-1)!.url, width, height, format: 'avif', srcset: variants.map(variant => `${variant.url} ${variant.width}w`).join(', ') }
62
+ return `export default ${JSON.stringify(image)}`
63
+ }
64
+
65
+ return {
66
+ files,
67
+ plugin: () => ({
68
+ name: '@master/internal-site:image-variants',
69
+ // Before Vite's asset handling and alongside Astro's image modules, which
70
+ // leave queried image ids to other plugins.
71
+ enforce: 'pre',
72
+ apply: 'build',
73
+ buildStart() { sharp.concurrency(encoderThreads) },
74
+ async resolveId(source, importer, options) {
75
+ if (!importer || importer.endsWith(marker) || source.includes('?') || !bitmap.test(source)) return
76
+ const resolved = await this.resolve(source, importer, { ...options, skipSelf: true })
77
+ if (!resolved || resolved.external || resolved.id.includes('?') || !bitmap.test(resolved.id)) return
78
+ return resolved.id + marker
79
+ },
80
+ async load(id) {
81
+ if (!id.endsWith(marker)) return
82
+ const file = cleanId(id)
83
+ this.addWatchFile(file)
84
+ if (!modules.has(file)) modules.set(file, load(file))
85
+ return await modules.get(file) ?? `export { default } from ${JSON.stringify(file)}`
86
+ }
87
+ }),
88
+ async write(outDir) {
89
+ for (const [name, bytes] of [...files].sort(([a], [b]) => a < b ? -1 : 1)) {
90
+ const target = join(outDir, name)
91
+ await mkdir(join(target, '..'), { recursive: true })
92
+ await writeFile(target, bytes)
93
+ }
94
+ }
95
+ }
96
+ }
@@ -15,8 +15,11 @@ import { prepareContentPages } from './content-pages'
15
15
  import { validateSiteTranslations } from '../scripts/localization'
16
16
  import serverStyles from './server-styles'
17
17
  import svgMarkup from './svg-markup'
18
+ import { imageVariants, type ImageVariants } from './image-variants'
18
19
  import localizedMdx from './localized-mdx'
19
20
  import { compactStyleAssets } from './style-assets'
21
+ import { writeTranslationAssets } from './site-translations'
22
+ import { hoistIslandScripts } from './island-scripts'
20
23
  import remarkSlugAndTOC from '../docs-shell/remark/slug-and-toc.js'
21
24
  import remarkMarkdownComponents from '../docs-shell/remark/markdown-components.js'
22
25
  import svgoConfig from '../docs-shell/common/svgo.config.js'
@@ -28,6 +31,7 @@ const propagated = (url: URL) => `() => import(${JSON.stringify(fsImport(url) +
28
31
 
29
32
  export default function masterSite(site: SiteConfig): AstroIntegration {
30
33
  let output: URL
34
+ let images: ImageVariants | undefined
31
35
  return {
32
36
  name: '@master/internal-site',
33
37
  hooks: {
@@ -102,10 +106,12 @@ export default function masterSite(site: SiteConfig): AstroIntegration {
102
106
  })
103
107
  }
104
108
  }
109
+ images = command === 'build' ? imageVariants({ base: config.base, assetsDir: config.build.assets }) : undefined
105
110
  addRenderer({
106
111
  name: '@master/internal-site:svelte',
107
112
  serverEntrypoint: fileURLToPath(new URL('./svelte-renderer.ts', import.meta.url)),
108
- clientEntrypoint: '@astrojs/svelte/client.js'
113
+ // Built pages deliver the island dictionary as a cached file; hydration waits for it.
114
+ clientEntrypoint: fileURLToPath(new URL('./svelte-client.ts', import.meta.url))
109
115
  })
110
116
  injectRoute({ pattern: '/[...path]', entrypoint: new URL('./Page.astro', import.meta.url), prerender: true })
111
117
  updateConfig({
@@ -114,8 +120,10 @@ export default function masterSite(site: SiteConfig): AstroIntegration {
114
120
  build: { format: 'file' },
115
121
  markdown: { processor: unified({ remarkPlugins: [remarkSlugAndTOC, remarkMarkdownComponents], gfm: true, smartypants: false }), syntaxHighlight: false },
116
122
  experimental: { svgOptimizer: svgoOptimizer(svgoConfig as Parameters<typeof svgoOptimizer>[0]) },
117
- vite: { plugins: [plugin, localizedMdx(site.i18n), sourceURL(), serverStyles([root, packageSource]), svgMarkup()], resolve: { dedupe: ['svelte'] }, server: { fs: { allow: [searchForWorkspaceRoot(root), packageSource] } } },
118
- integrations: [svelte(), mdx(), masterCSS({ mode: 'progressive' })]
123
+ vite: { plugins: [plugin, localizedMdx(site.i18n), sourceURL(), serverStyles([root, packageSource]), svgMarkup(), ...images ? [images.plugin()] : []], resolve: { dedupe: ['svelte'] }, server: { fs: { allow: [searchForWorkspaceRoot(root), packageSource] } } },
124
+ // Every route is prerendered and its island classes are present in the rendered HTML,
125
+ // so pages ship their Master CSS without a browser runtime or hydration manifest.
126
+ integrations: [svelte(), mdx(), masterCSS({ mode: 'prerender' })]
119
127
  })
120
128
  },
121
129
  'astro:config:done': ({ config }) => { output = config.outDir },
@@ -123,7 +131,9 @@ export default function masterSite(site: SiteConfig): AstroIntegration {
123
131
  const destination = output
124
132
  await mkdir(destination, { recursive: true })
125
133
  await cp(new URL('../public/', import.meta.url), destination, { recursive: true, force: false })
126
- await compactStyleAssets(fileURLToPath(destination))
134
+ await images?.write(fileURLToPath(destination))
135
+ await compactStyleAssets(fileURLToPath(destination), hoistIslandScripts)
136
+ await writeTranslationAssets(fileURLToPath(destination), site.i18n)
127
137
  }
128
138
  }
129
139
  }
@@ -0,0 +1,31 @@
1
+ export const islandScriptSlot = '<!--master-site:island-scripts-->'
2
+
3
+ const islandStyles = '<style>astro-island,astro-slot,astro-static-slot{display:contents}</style>'
4
+
5
+ /** Move Astro's first client:load preamble ahead of the stylesheets. A classic
6
+ * script after a stylesheet pauses HTML parsing until that stylesheet arrives. */
7
+ export function hoistIslandScripts(html: string) {
8
+ const slot = html.indexOf(islandScriptSlot)
9
+ if (slot < 0) return html
10
+ const unchanged = () => html.replace(islandScriptSlot, '')
11
+
12
+ const headEnd = html.indexOf('</head>', slot)
13
+ const body = html.indexOf('<body', headEnd)
14
+ if (headEnd < 0 || body < 0) return unchanged()
15
+
16
+ const island = html.indexOf('<astro-island ', body)
17
+ const styles = html.indexOf(islandStyles, body)
18
+ if (island < 0 || styles < 0 || styles > island) return unchanged()
19
+
20
+ const start = styles + islandStyles.length
21
+ const preamble = html.slice(start, island)
22
+ const scripts = preamble.match(/<script>[\s\S]*?<\/script>/g)
23
+ if (!scripts || scripts.length !== 2 || preamble.trim() !== scripts.join('')) return unchanged()
24
+ const [directive, runtime] = scripts
25
+ // Leave unfamiliar Astro output alone. Only the shell's known first island
26
+ // can supply this preamble. Author scripts and later directives stay put.
27
+ if (!directive.includes('astro:load') || !directive.includes('self.Astro')
28
+ || !runtime.includes('customElements.define(') || !runtime.includes('astro-island')) return unchanged()
29
+
30
+ return (html.slice(0, start) + html.slice(island)).replace(islandScriptSlot, directive + runtime)
31
+ }
@@ -0,0 +1,31 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdir, writeFile } from 'node:fs/promises'
3
+ import { resolve } from 'node:path'
4
+ import sharedTranslations from '../docs-shell/common/translations'
5
+
6
+ type Dictionaries = Record<string, Record<string, string>>
7
+
8
+ /** The island dictionary of one locale: shared interface copy, then the site's own, as `siteContextOf()` merges it. */
9
+ export function translationsOfLocale(siteTranslations: Dictionaries, locale: string): Record<string, string> {
10
+ return { ...sharedTranslations[locale], ...siteTranslations[locale] }
11
+ }
12
+
13
+ /**
14
+ * Built pages deliver the island dictionary as one content-addressed file per locale
15
+ * instead of serializing it into every page: browsers fetch it once and reuse it on
16
+ * every page of that locale. A locale without entries needs no file.
17
+ */
18
+ export function translationAsset(translations: Record<string, string>) {
19
+ if (!Object.keys(translations).length) return
20
+ const json = JSON.stringify(translations)
21
+ const hash = createHash('sha256').update(json).digest('hex').slice(0, 16)
22
+ return { href: `/_astro/site-translations-${hash}.json`, json }
23
+ }
24
+
25
+ /** Write the files that `translationAsset()` names for every locale into the build output. */
26
+ export async function writeTranslationAssets(output: string, i18n: { locales: readonly string[], translations: Dictionaries }) {
27
+ const assets = i18n.locales.map(locale => translationAsset(translationsOfLocale(i18n.translations, locale))).filter(asset => asset !== undefined)
28
+ if (!assets.length) return
29
+ await mkdir(resolve(output, '_astro'), { recursive: true })
30
+ await Promise.all(assets.map(({ href, json }) => writeFile(resolve(output, '.' + href), json)))
31
+ }
@@ -30,7 +30,7 @@ export async function inlineGeneratedStyles(file: string, ancestors: string[] =
30
30
 
31
31
  /** Preserve HTML link order and each reusable entry's cache identity. Originals
32
32
  * remain available to any JS/CSS references; no client loading logic is added. */
33
- export async function compactStyleAssets(output: string) {
33
+ export async function compactStyleAssets(output: string, transformPage = (html: string) => html) {
34
34
  const root = resolve(output), replacements = new Map<string, string>()
35
35
  const documents = new Map<string, string>(), users = new Map<string, string[]>()
36
36
  const boundaries = new Set<string>()
@@ -102,6 +102,6 @@ export async function compactStyleAssets(output: string) {
102
102
  await flush()
103
103
  html = html.replace(run[0], replacement)
104
104
  }
105
- await writeFile(file, html)
105
+ await writeFile(file, transformPage(html))
106
106
  }
107
107
  }
@@ -0,0 +1,13 @@
1
+ import hydrateSvelte from '@astrojs/svelte/client.js'
2
+ import { applySiteTranslations } from '../docs-shell/site-dictionary'
3
+
4
+ type Hydrate = ReturnType<typeof hydrateSvelte>
5
+
6
+ /** `@astrojs/svelte` hydration once the page's dictionary has reached the context `getSite()` reads. */
7
+ export default (element: HTMLElement) => {
8
+ const hydrate = hydrateSvelte(element)
9
+ return async (...args: Parameters<Hydrate>) => {
10
+ await applySiteTranslations()
11
+ return hydrate(...args)
12
+ }
13
+ }
@@ -30,7 +30,7 @@
30
30
  </script>
31
31
 
32
32
  <script lang="ts">
33
- /** Render a bitmap with intrinsic dimensions and, on Cloudflare, resized sources. */
33
+ /** Render a bitmap with intrinsic dimensions and resized sources: built AVIF candidates for imports, Cloudflare for URLs. */
34
34
  let { src, width, height, fill, priority, quality, placeholder: _placeholder, loading, sizes, style, alt = '', fetchpriority, ...props }: ImageProps = $props()
35
35
 
36
36
  const source = $derived(typeof src === 'string' ? src : src.src)
@@ -38,15 +38,18 @@
38
38
  const intrinsicHeight = $derived(fill ? undefined : Number(height ?? (typeof src === 'object' ? src.height : undefined)) || undefined)
39
39
  const url = (size: number) => createCloudflareImageUrl({ src: source, width: size, quality, siteUrl: import.meta.env.PUBLIC_URL })
40
40
  const widths = $derived(candidateWidths(intrinsicWidth, sizes))
41
- const resized = $derived(transformations && url(widths[0]) !== source)
41
+ const built = $derived(typeof src === 'object' ? src.srcset : undefined)
42
+ const resized = $derived(!built && transformations && url(widths[0]) !== source)
43
+ // Built candidates use width descriptors; a fixed display width tells the browser which one fits.
44
+ const builtSizes = $derived(sizes ?? (fill ? '100vw' : width ? `${Number(width)}px` : undefined))
42
45
  </script>
43
46
 
44
47
  <img
45
48
  {...props}
46
49
  {alt}
47
50
  src={resized ? url(widths.at(-1)!) : source}
48
- srcset={resized ? widths.map((size, index) => sizes || !intrinsicWidth ? `${url(size)} ${size}w` : `${url(size)} ${index + 1}x`).join(', ') : undefined}
49
- sizes={resized ? sizes ?? (fill ? '100vw' : undefined) : undefined}
51
+ srcset={built ?? (resized ? widths.map((size, index) => sizes || !intrinsicWidth ? `${url(size)} ${size}w` : `${url(size)} ${index + 1}x`).join(', ') : undefined)}
52
+ sizes={built ? builtSizes : resized ? sizes ?? (fill ? '100vw' : undefined) : undefined}
50
53
  width={intrinsicWidth}
51
54
  height={intrinsicHeight}
52
55
  loading={priority ? 'eager' : loading ?? 'lazy'}
@@ -5,7 +5,7 @@
5
5
  import type { Snippet } from 'svelte'
6
6
  import type { HTMLButtonAttributes } from 'svelte/elements'
7
7
  import IconSearch from '@tabler/icons-svelte-runes/icons/search'
8
- import { openSearch, searchLabels } from '../contexts/search'
8
+ import { openSearch, prefetchSearch, searchLabels } from '../contexts/search'
9
9
  import { getSite } from '../site'
10
10
 
11
11
  interface Props extends HTMLButtonAttributes {
@@ -13,7 +13,7 @@
13
13
  }
14
14
 
15
15
  /** Opens the page's search dialog. Without children it renders the search field trigger (`ui-search-trigger`) with its shortcut. */
16
- let { class: className, children, onclick, type = 'button', ...props }: Props = $props()
16
+ let { class: className, children, onclick, onfocus, onpointerenter, type = 'button', ...props }: Props = $props()
17
17
  const { t, locale } = getSite()
18
18
  const { searchText, searchLabel } = searchLabels(t, locale)
19
19
 
@@ -23,7 +23,9 @@
23
23
  }
24
24
  </script>
25
25
 
26
- <button {...props} {type} class={className} aria-label={props['aria-label'] ?? searchLabel} aria-keyshortcuts="Meta+K Control+K" onclick={handleClick}>
26
+ <button {...props} {type} class={className} aria-label={props['aria-label'] ?? searchLabel} aria-keyshortcuts="Meta+K Control+K" onclick={handleClick}
27
+ onpointerenter={event => { onpointerenter?.(event); prefetchSearch() }}
28
+ onfocus={event => { onfocus?.(event); prefetchSearch() }}>
27
29
  {#if children}
28
30
  {@render children()}
29
31
  {:else}
@@ -1,176 +1,62 @@
1
- <script lang="ts" module>
2
- import type { Attachment } from 'svelte/attachments'
3
- import type { DocumentationSearchPage } from '../utils/documentation-search'
4
-
5
- const cache: Record<string, Promise<DocumentationSearchPage[]>> = {}
6
-
7
- async function loadPages(locale: string) {
8
- cache[locale] ??= fetch(`/search/${encodeURIComponent(locale)}.json`).then(response => {
9
- if (!response.ok) throw new Error(`Search unavailable (${response.status})`)
10
- return response.json()
11
- }).catch(error => { delete cache[locale]; throw error })
12
- return cache[locale]
13
- }
14
-
15
- /** The dialog lives at the end of the body, outside the island that opens it. */
16
- const portal: Attachment<HTMLElement> = (node) => {
17
- document.body.append(node)
18
- return () => node.remove()
19
- }
20
- </script>
21
-
22
1
  <script lang="ts">
23
- import '@master/internal-site/styles/interface.css'
24
- import '@master/internal-site/styles/system/dialog.css'
25
-
26
- import '../../styles/docs-shell/documentation-search.css'
27
- import { untrack } from 'svelte'
28
- import Link from './Link.svelte'
29
- import { getSite } from '../site'
30
- import { createDocumentationSearch } from '../utils/documentation-search'
31
- import { openSearchEvent } from '../contexts/search'
32
- import IconArrowDown from '@tabler/icons-svelte-runes/icons/arrow-down'
33
- import IconArrowUp from '@tabler/icons-svelte-runes/icons/arrow-up'
34
- import IconCornerDownLeft from '@tabler/icons-svelte-runes/icons/corner-down-left'
35
- import IconSearch from '@tabler/icons-svelte-runes/icons/search'
36
- import IconX from '@tabler/icons-svelte-runes/icons/x'
37
- import { isInteractOutside } from '../utils/isInteractOutside'
38
-
39
- /** The page's single search dialog; search triggers in other islands open it by event (`contexts/search`). */
40
- const { t, locale } = getSite()
41
- let opened = $state(false)
42
- let query = $state('')
43
- let shortcutModifier = $state('⌘')
44
- let pages = $state.raw<DocumentationSearchPage[]>([])
45
- let error = $state('')
46
- let loading = $state(false)
47
- let retry = $state(0)
48
- let selected = $state(0)
49
- let dialog = $state<HTMLDialogElement>()
50
- let input = $state<HTMLInputElement>()
51
- let scroll = $state<HTMLDivElement>()
52
- let scrollTop = 0
53
- let returnFocus: HTMLElement | null = null
54
- const search = $derived(createDocumentationSearch(pages))
55
- const results = $derived(search(query))
56
-
57
- function selectQuery(value: string) {
58
- query = value
59
- selected = 0
60
- scrollTop = 0
61
- if (scroll) scroll.scrollTop = 0
62
- input?.focus()
63
- }
64
-
65
- function close() {
66
- scrollTop = scroll?.scrollTop ?? 0
67
- dialog?.close()
68
- opened = false
69
- returnFocus?.focus()
70
- }
71
-
72
- function open(event?: Event) {
73
- shortcutModifier = /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl'
74
- const trigger = (event as CustomEvent<{ trigger?: HTMLElement }> | undefined)?.detail?.trigger
75
- returnFocus = trigger instanceof HTMLElement ? trigger : document.activeElement as HTMLElement
76
- opened = true
77
- }
78
-
79
- $effect(() => {
80
- if (!opened) return
81
- void retry
82
- let active = true
83
- loading = true
84
- error = ''
85
- loadPages(locale).then(data => {
86
- if (active) { pages = data; loading = false }
87
- }, reason => {
88
- if (active) { error = reason.message; loading = false }
2
+ import type { Component } from 'svelte'
3
+ import { openSearchEvent, prefetchSearchEvent } from '../contexts/search'
4
+ // A client-only chunk delivers no CSS of its own: link the panel's compiled styles explicitly.
5
+ import searchStyles from '../../styles/docs-shell/documentation-search.css?url&no-inline'
6
+
7
+ type Panel = Component<{ initialTrigger?: HTMLElement | null }>
8
+
9
+ /** The page's single search dialog. Its panel and styles load on the first request or on intent
10
+ * (`prefetchSearch`), so a page does not deliver dialog CSS that its first render never uses. */
11
+ let Panel = $state<Panel>()
12
+ let initialTrigger = $state<HTMLElement | null>(null)
13
+ let loading: Promise<Panel | undefined> | undefined
14
+
15
+ function linkStylesheet(href: string) {
16
+ return new Promise<void>((resolve, reject) => {
17
+ if (document.querySelector(`link[rel="stylesheet"][href="${href}"]`)) return resolve()
18
+ const link = Object.assign(document.createElement('link'), { rel: 'stylesheet', href })
19
+ link.onload = () => resolve()
20
+ link.onerror = () => { link.remove(); reject(new Error(`Cannot load ${href}`)) }
21
+ document.head.append(link)
89
22
  })
90
- return () => { active = false }
91
- })
23
+ }
92
24
 
93
- $effect(() => {
94
- if (opened && dialog) {
95
- untrack(() => {
96
- if (!dialog!.open) dialog!.showModal()
97
- input?.focus()
98
- if (scroll) scroll.scrollTop = scrollTop
25
+ // The panel mounts only after its styles apply, so its first frame is styled.
26
+ function load() {
27
+ return loading ??= Promise.all([import('./SearchDialogPanel.svelte'), linkStylesheet(searchStyles)])
28
+ .then(([module]) => module.default)
29
+ .catch(error => {
30
+ loading = undefined
31
+ console.error('Search dialog could not load', error)
99
32
  })
100
- }
101
- })
102
-
103
- const closeOnBackdropClick: Attachment<HTMLDialogElement> = (element) => {
104
- const onclick = (event: MouseEvent) => {
105
- if (event.target === element && isInteractOutside(element, event)) close()
106
- }
107
- element.addEventListener('click', onclick)
108
- return () => element.removeEventListener('click', onclick)
109
33
  }
110
34
 
111
- $effect(() => {
112
- window.addEventListener(openSearchEvent, open)
113
- return () => window.removeEventListener(openSearchEvent, open)
114
- })
35
+ async function request(event: Event) {
36
+ const trigger = (event as CustomEvent<{ trigger?: HTMLElement }>).detail?.trigger
37
+ initialTrigger = trigger instanceof HTMLElement ? trigger : document.activeElement as HTMLElement | null
38
+ Panel = await load()
39
+ }
115
40
 
41
+ // Until the panel mounts, it owns no listeners: forward the first opening to it.
116
42
  function shortcut(event: KeyboardEvent) {
117
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); opened ? close() : open() }
43
+ if (Panel || !(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'k') return
44
+ event.preventDefault()
45
+ void request(event)
118
46
  }
119
47
 
120
- function onkeydown(event: KeyboardEvent) {
121
- if (event.isComposing || event.keyCode === 229) return
122
- if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return }
123
- if (event.key === 'Enter' && results.length) { event.preventDefault(); document.getElementById(`documentation-result-${selected}`)?.click() }
124
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
125
- event.preventDefault()
126
- const next = Math.max(0, Math.min(results.length - 1, selected + (event.key === 'ArrowDown' ? 1 : -1)))
127
- selected = next
128
- document.getElementById(`documentation-result-${next}`)?.scrollIntoView({ block: 'nearest' })
48
+ $effect(() => {
49
+ if (Panel) return
50
+ const prefetch = () => void load()
51
+ window.addEventListener(openSearchEvent, request)
52
+ window.addEventListener(prefetchSearchEvent, prefetch)
53
+ return () => {
54
+ window.removeEventListener(openSearchEvent, request)
55
+ window.removeEventListener(prefetchSearchEvent, prefetch)
129
56
  }
130
- }
131
-
132
- const kinds: Record<string, string> = { utility: 'Utility', rule: 'Rule', tokens: 'Tokens', directive: 'Directive', tool: 'Tool', package: 'API' }
133
-
57
+ })
134
58
  </script>
135
59
 
136
60
  <svelte:window onkeydown={shortcut} />
137
61
 
138
- {#snippet excerpt(text: string, query: string)}
139
- {@const index = text.toLowerCase().indexOf(query.toLowerCase())}
140
- {@const start = Math.max(0, index - 45)}
141
- {@const value = text.slice(start, start + 220)}
142
- {@const offset = value.toLowerCase().indexOf(query.toLowerCase())}
143
- {start > 0 ? '…' : ''}{#if offset < 0}{value}{:else}{value.slice(0, offset)}<mark>{value.slice(offset, offset + query.length)}</mark>{value.slice(offset + query.length)}{/if}{start + 220 < text.length ? '…' : ''}
144
- {/snippet}
145
-
146
- {#if opened}
147
- <dialog bind:this={dialog} {@attach portal} {@attach closeOnBackdropClick} class="ui-dialog" data-variant="search" aria-label={t('Search documentation')} oncancel={close}>
148
- <div class="ui-dialog-search">
149
- <IconSearch size={18} stroke={1.5} aria-hidden="true" />
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}
151
- oninput={event => selectQuery(event.currentTarget.value)}
152
- {onkeydown} />
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>
155
- </div>
156
- <div class="ui-dialog-body" bind:this={scroll}>
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}
159
- {#if !query && !error && !loading}<div class="ui-dialog-note">
160
- <p>{t('Try searching')}</p>
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}
162
- </div>{/if}
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}>
165
- {#if !error}{#each results as result, index (result.page.url)}<li role="presentation">
166
- <Link id={`documentation-result-${index}`} role="option" tabindex={-1} aria-selected={selected === index} href={result.href} onclick={close} onpointermove={() => { selected = index }}>
167
- <small>{t(result.page.category ?? '')} · {t(kinds[result.page.kind ?? ''] ?? (result.page.url.includes('/guide') ? 'Guide' : 'Page'))} · {t(result.reason)}</small>
168
- <b>{result.page.title}</b>
169
- <span>{@render excerpt(result.excerpt, query)}</span>
170
- </Link>
171
- </li>{/each}{/if}
172
- </ol>
173
- </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>
175
- </dialog>
176
- {/if}
62
+ {#if Panel}<Panel {initialTrigger} />{/if}