@master/internal-site 0.2.4 → 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.
@@ -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.4",
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,10 @@ 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'
20
22
  import { hoistIslandScripts } from './island-scripts'
21
23
  import remarkSlugAndTOC from '../docs-shell/remark/slug-and-toc.js'
22
24
  import remarkMarkdownComponents from '../docs-shell/remark/markdown-components.js'
@@ -29,6 +31,7 @@ const propagated = (url: URL) => `() => import(${JSON.stringify(fsImport(url) +
29
31
 
30
32
  export default function masterSite(site: SiteConfig): AstroIntegration {
31
33
  let output: URL
34
+ let images: ImageVariants | undefined
32
35
  return {
33
36
  name: '@master/internal-site',
34
37
  hooks: {
@@ -103,10 +106,12 @@ export default function masterSite(site: SiteConfig): AstroIntegration {
103
106
  })
104
107
  }
105
108
  }
109
+ images = command === 'build' ? imageVariants({ base: config.base, assetsDir: config.build.assets }) : undefined
106
110
  addRenderer({
107
111
  name: '@master/internal-site:svelte',
108
112
  serverEntrypoint: fileURLToPath(new URL('./svelte-renderer.ts', import.meta.url)),
109
- 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))
110
115
  })
111
116
  injectRoute({ pattern: '/[...path]', entrypoint: new URL('./Page.astro', import.meta.url), prerender: true })
112
117
  updateConfig({
@@ -115,8 +120,10 @@ export default function masterSite(site: SiteConfig): AstroIntegration {
115
120
  build: { format: 'file' },
116
121
  markdown: { processor: unified({ remarkPlugins: [remarkSlugAndTOC, remarkMarkdownComponents], gfm: true, smartypants: false }), syntaxHighlight: false },
117
122
  experimental: { svgOptimizer: svgoOptimizer(svgoConfig as Parameters<typeof svgoOptimizer>[0]) },
118
- vite: { plugins: [plugin, localizedMdx(site.i18n), sourceURL(), serverStyles([root, packageSource]), svgMarkup()], resolve: { dedupe: ['svelte'] }, server: { fs: { allow: [searchForWorkspaceRoot(root), packageSource] } } },
119
- 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' })]
120
127
  })
121
128
  },
122
129
  'astro:config:done': ({ config }) => { output = config.outDir },
@@ -124,7 +131,9 @@ export default function masterSite(site: SiteConfig): AstroIntegration {
124
131
  const destination = output
125
132
  await mkdir(destination, { recursive: true })
126
133
  await cp(new URL('../public/', import.meta.url), destination, { recursive: true, force: false })
134
+ await images?.write(fileURLToPath(destination))
127
135
  await compactStyleAssets(fileURLToPath(destination), hoistIslandScripts)
136
+ await writeTranslationAssets(fileURLToPath(destination), site.i18n)
128
137
  }
129
138
  }
130
139
  }
@@ -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
+ }
@@ -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}