@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.
- package/dist/astro.mjs +1868 -8
- package/dist/{localization-LfIbIysE.mjs → localization-IeZXkrNw.mjs} +11 -2
- package/dist/localization.mjs +1 -1
- package/package.json +3 -2
- package/src/astro/image-variants.ts +96 -0
- package/src/astro/index.ts +14 -4
- package/src/astro/island-scripts.ts +31 -0
- package/src/astro/site-translations.ts +31 -0
- package/src/astro/style-assets.ts +2 -2
- package/src/astro/svelte-client.ts +13 -0
- package/src/docs-shell/components/Image.svelte +7 -4
- package/src/docs-shell/components/SearchButton.svelte +5 -3
- package/src/docs-shell/components/SearchDialog.svelte +47 -161
- package/src/docs-shell/components/SearchDialogPanel.svelte +182 -0
- package/src/docs-shell/contexts/search.ts +8 -0
- package/src/docs-shell/contexts/theme-mode.svelte.ts +6 -1
- package/src/docs-shell/scripts/code.ts +9 -4
- package/src/docs-shell/site-dictionary.ts +36 -0
- package/src/docs-shell/types/StaticImage.ts +2 -0
- package/src/examples/components/ArticleTransitionScene.svelte +16 -14
- package/src/home/home-lamp.ts +35 -13
- package/src/home/icons/artwork/render-prerender-dark.webp +0 -0
- package/src/home/icons/artwork/render-prerender-light.webp +0 -0
- package/src/home/icons/artwork/source.json +6 -0
- package/src/home/icons/artwork.ts +3 -0
- package/src/home/icons/catalog.ts +9 -1
- package/src/home/icons/render-prerender.svg +12 -0
- package/src/layouts/Document.astro +28 -11
- package/src/scripts/localization.ts +13 -4
- package/src/styles/blog-article.css +5 -4
- 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) &&
|
|
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;
|
package/dist/localization.mjs
CHANGED
|
@@ -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-
|
|
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
|
+
"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": "
|
|
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
|
+
}
|
package/src/astro/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 '
|
|
24
|
-
import '
|
|
25
|
-
|
|
26
|
-
import '../../styles/docs-shell/documentation-search.css'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
91
|
-
})
|
|
23
|
+
}
|
|
92
24
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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)
|
|
43
|
+
if (Panel || !(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'k') return
|
|
44
|
+
event.preventDefault()
|
|
45
|
+
void request(event)
|
|
118
46
|
}
|
|
119
47
|
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
{#
|
|
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}
|