blume 1.6.6 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/cli/{chunk-nyqzjdhj.js → chunk-0qhq7b8q.js} +5 -5
- package/dist/cli/{chunk-cnvm6k3e.js → chunk-18tjv4f7.js} +11 -11
- package/dist/cli/{chunk-62qsssnh.js → chunk-5d4q7121.js} +401 -145
- package/dist/cli/chunk-5d4q7121.js.map +40 -0
- package/dist/cli/{chunk-ag1zyr5x.js → chunk-9qs6acpw.js} +11 -11
- package/dist/cli/{chunk-aerwpe14.js → chunk-agy5rzxy.js} +98 -15
- package/dist/cli/chunk-agy5rzxy.js.map +15 -0
- package/dist/cli/{chunk-x1vrdjyk.js → chunk-cfw6x4rm.js} +5 -5
- package/dist/cli/{chunk-bawgnt8x.js → chunk-ckh3a410.js} +3 -3
- package/dist/cli/{chunk-j00ezcg5.js → chunk-drke6t0h.js} +9 -9
- package/dist/cli/{chunk-3k0kzs6d.js → chunk-j6pxe0dt.js} +2 -2
- package/dist/cli/{chunk-n0y172hf.js → chunk-jk1zwka1.js} +4 -4
- package/dist/cli/{chunk-f75cqye8.js → chunk-jxkxjsc1.js} +10 -10
- package/dist/cli/{chunk-s4k1pnvf.js → chunk-kwx90v78.js} +11 -11
- package/dist/cli/{chunk-9sh49q0h.js → chunk-n0nyat6g.js} +2 -2
- package/dist/cli/{chunk-wkq5tbtq.js → chunk-qq9nm3qd.js} +3 -3
- package/dist/cli/{chunk-etsqspj6.js → chunk-s102bysw.js} +2 -2
- package/dist/cli/{chunk-wb067mv3.js → chunk-s5dsk8bj.js} +18 -7
- package/dist/cli/chunk-s5dsk8bj.js.map +13 -0
- package/dist/cli/{chunk-m3p3wahd.js → chunk-tnskyrej.js} +4 -4
- package/dist/cli/{chunk-vv237fp3.js → chunk-v2ymm99c.js} +26 -12
- package/dist/cli/{chunk-vv237fp3.js.map → chunk-v2ymm99c.js.map} +3 -3
- package/dist/cli/{chunk-5yvt556e.js → chunk-v5mm027v.js} +2 -2
- package/dist/cli/{chunk-vv3f8mb6.js → chunk-xv91q4nm.js} +24 -24
- package/dist/cli/{chunk-vv3f8mb6.js.map → chunk-xv91q4nm.js.map} +3 -3
- package/dist/cli/{chunk-0ewz4trd.js → chunk-y3g15rvv.js} +6 -6
- package/dist/cli/{chunk-tc89yh2r.js → chunk-ye9zdkgv.js} +2 -2
- package/dist/cli/{chunk-n4qjabmt.js → chunk-ynacq3ev.js} +4 -4
- package/dist/cli/{chunk-s4jn7f1q.js → chunk-zr3ygrq3.js} +2 -2
- package/dist/cli/index.js +13 -13
- package/dist/types/components/layout/nav-utils.d.ts +33 -1
- package/dist/types/theme/fonts.d.ts +22 -22
- package/docs/02-deployment.mdx +21 -0
- package/docs/content/navigation.mdx +2 -0
- package/docs/content/syntax.mdx +1 -1
- package/docs/discoverability/open-graph.mdx +4 -0
- package/package.json +1 -1
- package/src/astro/generate.ts +141 -5
- package/src/astro/integration.ts +12 -1
- package/src/astro/module-types.ts +9 -0
- package/src/astro/templates.ts +171 -11
- package/src/cli/commands/build.ts +28 -0
- package/src/components/Icon.astro +24 -0
- package/src/components/icon-sprite-middleware.ts +41 -0
- package/src/components/icon-sprite.ts +93 -0
- package/src/components/layout/IconSprite.astro +11 -0
- package/src/components/layout/NavTree.astro +156 -188
- package/src/components/layout/NavTreeCache.astro +45 -0
- package/src/components/layout/NavTreeScript.astro +256 -0
- package/src/components/layout/PageActions.astro +11 -5
- package/src/components/layout/PageLayout.astro +7 -0
- package/src/components/layout/ReferenceLayout.astro +7 -0
- package/src/components/layout/RootLayout.astro +30 -2
- package/src/components/layout/nav-cache.ts +49 -0
- package/src/components/layout/nav-utils.ts +69 -1
- package/src/markdown/language-icon.ts +64 -20
- package/src/markdown/mermaid.ts +11 -0
- package/src/og/cache.ts +236 -0
- package/src/og/card.ts +12 -4
- package/src/og/index.ts +8 -1
- package/src/registry/eject.ts +23 -8
- package/src/theme/entry.ts +41 -7
- package/src/theme/fonts.ts +30 -23
- package/dist/cli/chunk-62qsssnh.js.map +0 -36
- package/dist/cli/chunk-aerwpe14.js.map +0 -15
- package/dist/cli/chunk-wb067mv3.js.map +0 -13
- /package/dist/cli/{chunk-nyqzjdhj.js.map → chunk-0qhq7b8q.js.map} +0 -0
- /package/dist/cli/{chunk-cnvm6k3e.js.map → chunk-18tjv4f7.js.map} +0 -0
- /package/dist/cli/{chunk-ag1zyr5x.js.map → chunk-9qs6acpw.js.map} +0 -0
- /package/dist/cli/{chunk-x1vrdjyk.js.map → chunk-cfw6x4rm.js.map} +0 -0
- /package/dist/cli/{chunk-bawgnt8x.js.map → chunk-ckh3a410.js.map} +0 -0
- /package/dist/cli/{chunk-j00ezcg5.js.map → chunk-drke6t0h.js.map} +0 -0
- /package/dist/cli/{chunk-3k0kzs6d.js.map → chunk-j6pxe0dt.js.map} +0 -0
- /package/dist/cli/{chunk-n0y172hf.js.map → chunk-jk1zwka1.js.map} +0 -0
- /package/dist/cli/{chunk-f75cqye8.js.map → chunk-jxkxjsc1.js.map} +0 -0
- /package/dist/cli/{chunk-s4k1pnvf.js.map → chunk-kwx90v78.js.map} +0 -0
- /package/dist/cli/{chunk-9sh49q0h.js.map → chunk-n0nyat6g.js.map} +0 -0
- /package/dist/cli/{chunk-wkq5tbtq.js.map → chunk-qq9nm3qd.js.map} +0 -0
- /package/dist/cli/{chunk-etsqspj6.js.map → chunk-s102bysw.js.map} +0 -0
- /package/dist/cli/{chunk-m3p3wahd.js.map → chunk-tnskyrej.js.map} +0 -0
- /package/dist/cli/{chunk-5yvt556e.js.map → chunk-v5mm027v.js.map} +0 -0
- /package/dist/cli/{chunk-0ewz4trd.js.map → chunk-y3g15rvv.js.map} +0 -0
- /package/dist/cli/{chunk-tc89yh2r.js.map → chunk-ye9zdkgv.js.map} +0 -0
- /package/dist/cli/{chunk-n4qjabmt.js.map → chunk-ynacq3ev.js.map} +0 -0
- /package/dist/cli/{chunk-s4jn7f1q.js.map → chunk-zr3ygrq3.js.map} +0 -0
|
@@ -66,6 +66,15 @@ declare module "blume:openapi" {
|
|
|
66
66
|
export default specs;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
declare module "blume:features" {
|
|
70
|
+
/** Registers the <blume-mermaid> element; null when no page has a mermaid fence. */
|
|
71
|
+
export const loadMermaid: (() => Promise<unknown>) | null;
|
|
72
|
+
/** The EPUB generator's browser bundle; null when export.epub is off. */
|
|
73
|
+
export const loadEpub:
|
|
74
|
+
| (() => Promise<typeof import("epub-gen-memory/bundle")>)
|
|
75
|
+
| null;
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
declare module "blume:search-client" {
|
|
70
79
|
export const createSearch: () =>
|
|
71
80
|
| import("blume/components/layout/search/types.ts").SearchFn
|
package/src/astro/templates.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { BLUME_IGNORE_DIRS } from "../core/sources/watch.ts";
|
|
|
14
14
|
import { trimChar } from "../core/trim.ts";
|
|
15
15
|
import type { ProjectContext } from "../core/types.ts";
|
|
16
16
|
import { applyBaseToAstroRedirects } from "../deploy/redirects.ts";
|
|
17
|
+
import type { OgCache } from "../og/cache.ts";
|
|
17
18
|
import type { OgFont, OgFontFamilies } from "../og/card.ts";
|
|
18
19
|
import { hasScalarReferences } from "../openapi/references.ts";
|
|
19
20
|
import { searchProviderMeta } from "../search/providers.ts";
|
|
@@ -137,6 +138,18 @@ const resolveSessionOption = (deployment: {
|
|
|
137
138
|
? "\n session: false,"
|
|
138
139
|
: "";
|
|
139
140
|
|
|
141
|
+
/**
|
|
142
|
+
* A font weight as Astro's Fonts API spells it: a variable range is
|
|
143
|
+
* `"100 900"` there, where Blume's config (and Takumi's Google Fonts helper,
|
|
144
|
+
* which the OG cards use) write `"100..900"`. Astro treats the dotted form as
|
|
145
|
+
* an unknown discrete weight and loads nothing for it.
|
|
146
|
+
*/
|
|
147
|
+
const astroFontWeights = (weights: (number | string)[]): string =>
|
|
148
|
+
JSON.stringify(weights).replaceAll(
|
|
149
|
+
/(?<min>\d+)\.\.(?<max>\d+)/gu,
|
|
150
|
+
"$<min> $<max>"
|
|
151
|
+
);
|
|
152
|
+
|
|
140
153
|
/** The named imports the generated config pulls from `astro/config`. */
|
|
141
154
|
const astroConfigImportLine = (options: { hasFonts: boolean }): string => {
|
|
142
155
|
const names = ["defineConfig"];
|
|
@@ -389,13 +402,31 @@ interface OptimizeDepsConfig {
|
|
|
389
402
|
* absolute path (see `reactIntegration`), which that check never matches. See
|
|
390
403
|
* the optimizeDeps comment in the generated config for the failure this prevents.
|
|
391
404
|
*/
|
|
405
|
+
/**
|
|
406
|
+
* The client-side libraries a site needs, decided at generation time. A
|
|
407
|
+
* feature no page uses is left out of the module graph entirely (see
|
|
408
|
+
* {@link featuresTemplate}), so its library never enters the client bundle —
|
|
409
|
+
* Mermaid alone is over 3 MB of chunks (ELK, Cytoscape, KaTeX, every diagram
|
|
410
|
+
* type) and most of the build's client-bundle memory.
|
|
411
|
+
*/
|
|
412
|
+
export interface ClientFeatures {
|
|
413
|
+
/** `export.epub` is on: the EPUB generator's browser bundle is needed. */
|
|
414
|
+
epub: boolean;
|
|
415
|
+
/** Some page has a mermaid fence: the `<blume-mermaid>` element is needed. */
|
|
416
|
+
mermaid: boolean;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Every feature on: what a checkout that predates the detection shipped. */
|
|
420
|
+
const ALL_CLIENT_FEATURES: ClientFeatures = { epub: true, mermaid: true };
|
|
421
|
+
|
|
392
422
|
const resolveOptimizeDeps = (options: {
|
|
393
423
|
aliases: Record<string, string> | undefined;
|
|
394
424
|
context: ProjectContext;
|
|
425
|
+
features: ClientFeatures;
|
|
395
426
|
needsReact: boolean;
|
|
396
427
|
reactCompilerPath: string | null | undefined;
|
|
397
428
|
}): OptimizeDepsConfig => {
|
|
398
|
-
const { context } = options;
|
|
429
|
+
const { context, features } = options;
|
|
399
430
|
const optimizeDepsEntries = [
|
|
400
431
|
...(context.pagesRoot ? [`${context.pagesRoot}/**/*.astro`] : []),
|
|
401
432
|
`${context.root}/islands/**/*.{jsx,svelte,tsx,vue}`,
|
|
@@ -404,8 +435,10 @@ const resolveOptimizeDeps = (options: {
|
|
|
404
435
|
.map((dir) => `${dir}/**/*.{astro,jsx,svelte,tsx,vue}`),
|
|
405
436
|
];
|
|
406
437
|
const optimizeDepsInclude = [
|
|
407
|
-
|
|
408
|
-
|
|
438
|
+
// Only the libraries the site's features actually import: pre-bundling
|
|
439
|
+
// Mermaid costs the dev server seconds at startup for nothing otherwise.
|
|
440
|
+
...(features.mermaid ? ["blume > mermaid"] : []),
|
|
441
|
+
...(features.epub ? ["blume > epub-gen-memory/bundle"] : []),
|
|
409
442
|
// Astro's own client-router/prefetch virtual modules are deliberately NOT
|
|
410
443
|
// forced in here: they read Vite `define`-injected constants
|
|
411
444
|
// (__PREFETCH_PREFETCH_ALL__ and friends) that a pre-bundled copy loses,
|
|
@@ -494,6 +527,10 @@ export const astroConfigTemplate = (options: {
|
|
|
494
527
|
examplesThemePath: string;
|
|
495
528
|
themePath: string;
|
|
496
529
|
searchClientPath: string;
|
|
530
|
+
/** The generated client-feature loaders (`blume:features`). */
|
|
531
|
+
featuresPath: string;
|
|
532
|
+
/** Which client features the site uses; every feature on when omitted. */
|
|
533
|
+
features?: ClientFeatures;
|
|
497
534
|
/**
|
|
498
535
|
* Where the runtime data modules (`blume:data`, the search index, …) live as
|
|
499
536
|
* JSON files, for a project with no CLI to publish them in memory (eject):
|
|
@@ -528,6 +565,8 @@ export const astroConfigTemplate = (options: {
|
|
|
528
565
|
contentRoutes,
|
|
529
566
|
examplesPath,
|
|
530
567
|
examplesThemePath,
|
|
568
|
+
features = ALL_CLIENT_FEATURES,
|
|
569
|
+
featuresPath,
|
|
531
570
|
generatedModulesDir,
|
|
532
571
|
needsSvelte,
|
|
533
572
|
needsVue,
|
|
@@ -549,6 +588,7 @@ export const astroConfigTemplate = (options: {
|
|
|
549
588
|
const { optimizeDepsEntries, optimizeDepsInclude } = resolveOptimizeDeps({
|
|
550
589
|
aliases: options.aliases,
|
|
551
590
|
context,
|
|
591
|
+
features,
|
|
552
592
|
needsReact,
|
|
553
593
|
reactCompilerPath: options.reactCompilerPath,
|
|
554
594
|
});
|
|
@@ -666,9 +706,7 @@ export const astroConfigTemplate = (options: {
|
|
|
666
706
|
font.name
|
|
667
707
|
)}, cssVariable: ${JSON.stringify(
|
|
668
708
|
font.cssVariable
|
|
669
|
-
)}, weights: ${JSON.stringify(
|
|
670
|
-
font.weights
|
|
671
|
-
)}, subsets: ${JSON.stringify(
|
|
709
|
+
)}, weights: ${astroFontWeights(font.weights)}, subsets: ${JSON.stringify(
|
|
672
710
|
font.subsets
|
|
673
711
|
)}, fallbacks: ${JSON.stringify(font.fallbacks)} }`
|
|
674
712
|
)
|
|
@@ -750,7 +788,8 @@ export const astroConfigTemplate = (options: {
|
|
|
750
788
|
} = renderIntegrationBridge(options.integrationBridge);
|
|
751
789
|
|
|
752
790
|
return `// Generated by Blume. Do not edit; this file is recreated on each run.
|
|
753
|
-
${configSourceMarker}${userConfigImports}
|
|
791
|
+
${configSourceMarker}${userConfigImports}import { availableParallelism } from "node:os";
|
|
792
|
+
${defineConfigImport}
|
|
754
793
|
import mdx from "@astrojs/mdx";
|
|
755
794
|
import tailwindcss from "@tailwindcss/vite";
|
|
756
795
|
import { blumeMarkdownProcessor, blumeMdxProcessor, blumeShikiTransformers, blumeTwoslashTransformer } from "blume/markdown";
|
|
@@ -795,6 +834,12 @@ ${userConfigSetup}export default defineConfig({
|
|
|
795
834
|
// request latency behind the user's intent, so most navigations swap
|
|
796
835
|
// instantly.
|
|
797
836
|
prefetch: { prefetchAll: true },
|
|
837
|
+
// Prerender several pages at once. Rendering is single-threaded, but a
|
|
838
|
+
// page's OG card renders on a native thread and its HTML is written
|
|
839
|
+
// asynchronously, so the main thread would otherwise idle behind each
|
|
840
|
+
// page's off-thread work. Capped at 8: the gain flattens there and beyond it
|
|
841
|
+
// the overlap only adds memory.
|
|
842
|
+
build: { concurrency: Math.min(8, availableParallelism()) },
|
|
798
843
|
vite: {${viteCacheOption}
|
|
799
844
|
plugins: [${runtimeModulesPluginEntry}tailwindcss(), includeHmrPlugin(${JSON.stringify(
|
|
800
845
|
`${context.outDir}/src/generated/includes.json`
|
|
@@ -847,6 +892,7 @@ ${userConfigSetup}export default defineConfig({
|
|
|
847
892
|
"blume:ask": ${JSON.stringify(askPath)},
|
|
848
893
|
"blume:examples": ${JSON.stringify(examplesPath)},
|
|
849
894
|
"blume:examples-theme": ${JSON.stringify(examplesThemePath)},
|
|
895
|
+
"blume:features": ${JSON.stringify(featuresPath)},
|
|
850
896
|
"blume:search-client": ${JSON.stringify(searchClientPath)},
|
|
851
897
|
"blume:theme": ${JSON.stringify(themePath)},${runtimeModuleAliasLines}${userAliasLines}
|
|
852
898
|
},
|
|
@@ -1289,6 +1335,34 @@ const hostedSearchOptions = (
|
|
|
1289
1335
|
}
|
|
1290
1336
|
};
|
|
1291
1337
|
|
|
1338
|
+
/**
|
|
1339
|
+
* Generate `.blume/src/generated/features.ts` — the client-feature loaders
|
|
1340
|
+
* behind the `blume:features` alias. Each loader is a dynamic import when the
|
|
1341
|
+
* site uses the feature and `null` otherwise, so an unused library is absent
|
|
1342
|
+
* from the module graph (never bundled, never pre-bundled in dev) rather than
|
|
1343
|
+
* merely lazy. Regenerated on every pass: a page that gains a mermaid fence
|
|
1344
|
+
* turns the loader on.
|
|
1345
|
+
*/
|
|
1346
|
+
export const featuresTemplate = (features: ClientFeatures): string =>
|
|
1347
|
+
`// Generated by Blume. Do not edit.
|
|
1348
|
+
//
|
|
1349
|
+
// The client features this site uses. A feature no page needs stays out of
|
|
1350
|
+
// the module graph entirely: its loader is null and its library never enters
|
|
1351
|
+
// the client bundle.
|
|
1352
|
+
|
|
1353
|
+
/** Registers the <blume-mermaid> element; null when no page has a mermaid fence. */
|
|
1354
|
+
export const loadMermaid: (() => Promise<unknown>) | null = ${
|
|
1355
|
+
features.mermaid
|
|
1356
|
+
? '() => import("blume/components/content/mermaid-element.ts")'
|
|
1357
|
+
: "null"
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
/** The EPUB generator's browser bundle; null when export.epub is off. */
|
|
1361
|
+
export const loadEpub:
|
|
1362
|
+
| (() => Promise<typeof import("epub-gen-memory/bundle")>)
|
|
1363
|
+
| null = ${features.epub ? '() => import("epub-gen-memory/bundle")' : "null"};
|
|
1364
|
+
`;
|
|
1365
|
+
|
|
1292
1366
|
/**
|
|
1293
1367
|
* Generate `.blume/src/generated/search-client.ts` — the provider-specific
|
|
1294
1368
|
* loader the `<Search>` component lazy-imports via the `blume:search-client`
|
|
@@ -1610,10 +1684,77 @@ export function GET({ props }: { props: { section: string } }) {
|
|
|
1610
1684
|
}
|
|
1611
1685
|
`;
|
|
1612
1686
|
|
|
1687
|
+
/**
|
|
1688
|
+
* Generate the deferred sidebar fragments page
|
|
1689
|
+
* (`.blume/src/pages/blume-nav/[version]/[locale]/[id].astro`): one
|
|
1690
|
+
* prerendered partial per collapsible or drill-in group, holding just that
|
|
1691
|
+
* group's rows, which the sidebar fetches on first open instead of carrying
|
|
1692
|
+
* every collapsed section on every page. Ids are `navGroupIds` over the full
|
|
1693
|
+
* tree, the same ids the pages' sidebars use; the `version`/`locale`
|
|
1694
|
+
* segments pick the navigation variant (`current`/`default` for the
|
|
1695
|
+
* unversioned, unlocalized tree). Rendered with no current route — a
|
|
1696
|
+
* section that isn't on the active path has no active row by definition.
|
|
1697
|
+
*/
|
|
1698
|
+
export const navFragmentTemplate = (): string =>
|
|
1699
|
+
`---
|
|
1700
|
+
// Generated by Blume. Do not edit.
|
|
1701
|
+
import NavTree from "blume/components/layout/NavTree.astro";
|
|
1702
|
+
import { withBase } from "blume/components/islands/base-path.ts";
|
|
1703
|
+
import { navGroupIds, navVariants } from "blume/components/layout/nav-utils.ts";
|
|
1704
|
+
import type { NavNode } from "blume/core/types.ts";
|
|
1705
|
+
import data from "blume:data";
|
|
1706
|
+
|
|
1707
|
+
export const prerender = true;
|
|
1708
|
+
// A partial: no doctype, head, or script/style injection — the rows only.
|
|
1709
|
+
export const partial = true;
|
|
1710
|
+
|
|
1711
|
+
// Only imports are in scope here (Astro hoists getStaticPaths), which is why
|
|
1712
|
+
// the variant walk lives in nav-utils.
|
|
1713
|
+
export function getStaticPaths() {
|
|
1714
|
+
return navVariants(data).flatMap(({ locale, navigation, version }) =>
|
|
1715
|
+
[...navGroupIds(navigation.sidebar)]
|
|
1716
|
+
.filter(([node]) => (node.display ?? "flat") !== "flat")
|
|
1717
|
+
.map(([, id]) => ({ params: { id, locale, version } }))
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
const { id, locale, version } = Astro.params;
|
|
1722
|
+
const variant = navVariants(data).find(
|
|
1723
|
+
(entry) => entry.version === version && entry.locale === locale
|
|
1724
|
+
);
|
|
1725
|
+
const ids = variant
|
|
1726
|
+
? navGroupIds(variant.navigation.sidebar)
|
|
1727
|
+
: new Map<NavNode, string>();
|
|
1728
|
+
const node = [...ids].find(([, groupId]) => groupId === id)?.[0];
|
|
1729
|
+
if (!(variant && node && node.kind === "group")) {
|
|
1730
|
+
return new Response(null, { status: 404 });
|
|
1731
|
+
}
|
|
1732
|
+
const strings =
|
|
1733
|
+
locale === "default" ? data.ui.nav : (data.uiByLocale[locale] ?? data.ui).nav;
|
|
1734
|
+
const fragmentBase = withBase(\`/blume-nav/\${version}/\${locale}\`);
|
|
1735
|
+
---
|
|
1736
|
+
|
|
1737
|
+
<NavTree
|
|
1738
|
+
currentRoute=""
|
|
1739
|
+
depth={1}
|
|
1740
|
+
fragmentBase={fragmentBase}
|
|
1741
|
+
idPrefix={id}
|
|
1742
|
+
ids={ids}
|
|
1743
|
+
items={node.children}
|
|
1744
|
+
root={false}
|
|
1745
|
+
strings={strings}
|
|
1746
|
+
/>
|
|
1747
|
+
`;
|
|
1748
|
+
|
|
1613
1749
|
/** Generate the OG image endpoint (`.blume/src/pages/og/[...slug].png.ts`). */
|
|
1614
1750
|
export const ogEndpointTemplate = (
|
|
1615
1751
|
customRoutes: OgCustomRoute[] = [],
|
|
1616
1752
|
og: {
|
|
1753
|
+
/**
|
|
1754
|
+
* The on-disk card cache (directory plus the rendering Blume version).
|
|
1755
|
+
* Absent, every card renders on every build.
|
|
1756
|
+
*/
|
|
1757
|
+
cache?: OgCache;
|
|
1617
1758
|
families?: OgFontFamilies;
|
|
1618
1759
|
fonts?: OgFont[];
|
|
1619
1760
|
/**
|
|
@@ -1626,8 +1767,8 @@ export const ogEndpointTemplate = (
|
|
|
1626
1767
|
includeChangelog = false
|
|
1627
1768
|
): string =>
|
|
1628
1769
|
`// Generated by Blume. Do not edit.
|
|
1629
|
-
import {
|
|
1630
|
-
import type { OgFont, OgFontFamilies } from "blume/og";
|
|
1770
|
+
import { cachedOgImage } from "blume/og";
|
|
1771
|
+
import type { OgCache, OgFont, OgFontFamilies } from "blume/og";
|
|
1631
1772
|
import data from "blume:data";
|
|
1632
1773
|
|
|
1633
1774
|
export const prerender = true;
|
|
@@ -1646,6 +1787,14 @@ const families: OgFontFamilies | undefined = ${
|
|
|
1646
1787
|
og.families ? JSON.stringify(og.families) : "undefined"
|
|
1647
1788
|
};
|
|
1648
1789
|
|
|
1790
|
+
// Rendered cards are kept on disk between builds, keyed by everything that
|
|
1791
|
+
// decides their pixels, so a rebuild renders only the cards whose title,
|
|
1792
|
+
// description, branding, or fonts changed. The directory is a build-machine
|
|
1793
|
+
// path, baked in for the same reason as the local font paths above.
|
|
1794
|
+
const cache: OgCache | undefined = ${
|
|
1795
|
+
og.cache ? JSON.stringify(og.cache) : "undefined"
|
|
1796
|
+
};
|
|
1797
|
+
|
|
1649
1798
|
// A page's own description (its \`seo.description\`, else \`description\`) is
|
|
1650
1799
|
// the card subtitle, so the image says what the page's og:description says.
|
|
1651
1800
|
// Pages without one fall back to the site-wide subtitle at render time.
|
|
@@ -1705,7 +1854,7 @@ const repoSlug = data.config.github
|
|
|
1705
1854
|
: undefined;
|
|
1706
1855
|
|
|
1707
1856
|
export async function GET({ props }: { props: CardProps }) {
|
|
1708
|
-
const png = await
|
|
1857
|
+
const png = await cachedOgImage(cache, {
|
|
1709
1858
|
accent: data.config.og.palette?.accent ?? data.config.theme.accent.light,
|
|
1710
1859
|
brand: data.config.title,
|
|
1711
1860
|
description: props.description ?? data.config.og.description,
|
|
@@ -1788,6 +1937,12 @@ export const catchAllPageTemplate = (options: {
|
|
|
1788
1937
|
exportEpub: boolean;
|
|
1789
1938
|
exportPdf: boolean;
|
|
1790
1939
|
mathEnabled: boolean;
|
|
1940
|
+
/**
|
|
1941
|
+
* Whether the sidebar defers collapsed sections to prerendered fragments
|
|
1942
|
+
* (see `navFragmentTemplate`); true when some group renders as a
|
|
1943
|
+
* disclosure or a drill-in panel.
|
|
1944
|
+
*/
|
|
1945
|
+
navFragments?: boolean;
|
|
1791
1946
|
/** Serialize the island-hooks snapshot; only needed when React is enabled. */
|
|
1792
1947
|
needsReact: boolean;
|
|
1793
1948
|
}): string => {
|
|
@@ -2166,7 +2321,12 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
2166
2321
|
editUrl={editUrl}
|
|
2167
2322
|
feedback={data.config.feedback}
|
|
2168
2323
|
exportPdf={${options.exportPdf}}
|
|
2169
|
-
exportEpub={${options.exportEpub}}
|
|
2324
|
+
exportEpub={${options.exportEpub}}${
|
|
2325
|
+
options.navFragments
|
|
2326
|
+
? `
|
|
2327
|
+
navFragmentBase={withBase(\`/blume-nav/\${version || "current"}/\${i18n ? locale : "default"}\`)}`
|
|
2328
|
+
: ""
|
|
2329
|
+
}
|
|
2170
2330
|
openInChat={data.config.openInChat}
|
|
2171
2331
|
feeds={data.feeds}
|
|
2172
2332
|
discovery={data.config.discovery}
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "../../deploy/function-bundle.ts";
|
|
46
46
|
import { platformRedirects } from "../../deploy/redirects.ts";
|
|
47
47
|
import { injectNegotiationRoutes } from "../../deploy/vercel-negotiation.ts";
|
|
48
|
+
import { cardCacheTally, ogCacheDir, pruneCardCache } from "../../og/cache.ts";
|
|
48
49
|
import { commandMeta } from "../command-meta.ts";
|
|
49
50
|
import { refuseIfDevRunning } from "../dev-lock.ts";
|
|
50
51
|
import { logger } from "../log.ts";
|
|
@@ -248,6 +249,29 @@ const emitCloudflareNegotiation = async (
|
|
|
248
249
|
);
|
|
249
250
|
};
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Report how many OG cards the build read back from the on-disk cache against
|
|
254
|
+
* how many it rendered (the endpoint tallies both), so a warm rebuild shows
|
|
255
|
+
* where the time went. With `prune`, also drop the cached cards this build
|
|
256
|
+
* never asked for — renamed pages, edited descriptions, cards from an earlier
|
|
257
|
+
* Blume version — so a persisted cache holds exactly the current site's cards.
|
|
258
|
+
*/
|
|
259
|
+
const reportCardCache = async (
|
|
260
|
+
project: BlumeProject,
|
|
261
|
+
prune: boolean
|
|
262
|
+
): Promise<void> => {
|
|
263
|
+
const cards = cardCacheTally();
|
|
264
|
+
if (!cards) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
logger.info(
|
|
268
|
+
`OG cards: ${cards.hits} reused from the cache, ${cards.misses} rendered`
|
|
269
|
+
);
|
|
270
|
+
if (prune) {
|
|
271
|
+
await pruneCardCache(ogCacheDir(project.context));
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
251
275
|
const formatBytes = (bytes: number): string => {
|
|
252
276
|
if (bytes < 1024) {
|
|
253
277
|
return `${bytes} B`;
|
|
@@ -557,6 +581,10 @@ export const buildCommand = defineCommand({
|
|
|
557
581
|
root: project.context.outDir,
|
|
558
582
|
});
|
|
559
583
|
|
|
584
|
+
// A real build also prunes the cache; an isolated verify must not evict
|
|
585
|
+
// cards a live dev server is still serving.
|
|
586
|
+
await reportCardCache(project, !runtimeDir);
|
|
587
|
+
|
|
560
588
|
// The bundle report and budget gate still run for an isolated build —
|
|
561
589
|
// `blume build --isolated --budget-js 100` exiting 0 without measuring
|
|
562
590
|
// anything would be a silent false pass in CI.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import { isImageIcon, isInlineSvg } from "../theme/icon-kind.ts";
|
|
3
3
|
import { resolveIcon } from "../theme/icons.ts";
|
|
4
|
+
import { iconSpriteFor, registerIconSymbol } from "./icon-sprite.ts";
|
|
4
5
|
import { withBase } from "./islands/base-path.ts";
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
@@ -32,6 +33,17 @@ const rawSvg = iconName && isInlineSvg(iconName) ? iconName.trim() : null;
|
|
|
32
33
|
const imageSrc = iconName && !rawSvg && isImageIcon(iconName) ? iconName : null;
|
|
33
34
|
const resolvedIcon =
|
|
34
35
|
iconName && !(imageSrc || rawSvg) ? resolveIcon(iconName) : null;
|
|
36
|
+
// A set icon joins the page's sprite when the shell keeps one (see
|
|
37
|
+
// icon-sprite.ts): the paths render once, in the sprite, and this use is a
|
|
38
|
+
// <use> reference. Without a sprite the icon inlines its paths as before.
|
|
39
|
+
const sprite = resolvedIcon ? iconSpriteFor(Astro.locals) : undefined;
|
|
40
|
+
const symbolId =
|
|
41
|
+
sprite && resolvedIcon
|
|
42
|
+
? registerIconSymbol(sprite, resolvedIcon.name, {
|
|
43
|
+
body: resolvedIcon.body,
|
|
44
|
+
viewBox: resolvedIcon.viewBox,
|
|
45
|
+
})
|
|
46
|
+
: null;
|
|
35
47
|
const resolvedClass = astroClass ?? className;
|
|
36
48
|
const customStyle = `display:inline-flex;width:${size}px;height:${size}px;${
|
|
37
49
|
color ? `color:${color}` : ""
|
|
@@ -68,6 +80,18 @@ const style = color ? `color:${color}` : undefined;
|
|
|
68
80
|
src={withBase(imageSrc)}
|
|
69
81
|
width={size}
|
|
70
82
|
/>
|
|
83
|
+
) : symbolId ? (
|
|
84
|
+
<svg
|
|
85
|
+
aria-hidden={label ? undefined : "true"}
|
|
86
|
+
aria-label={label}
|
|
87
|
+
class={resolvedClass}
|
|
88
|
+
height={size}
|
|
89
|
+
role={label ? "img" : undefined}
|
|
90
|
+
style={style}
|
|
91
|
+
width={size}
|
|
92
|
+
>
|
|
93
|
+
<use href={`#${symbolId}`} />
|
|
94
|
+
</svg>
|
|
71
95
|
) : (
|
|
72
96
|
resolvedIcon && (
|
|
73
97
|
<svg
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "astro";
|
|
2
|
+
|
|
3
|
+
import { iconSpriteFor, renderIconSprite } from "./icon-sprite.ts";
|
|
4
|
+
import type { IconSprite } from "./icon-sprite.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The placeholder a shell renders where its icon sprite goes (the end of the
|
|
8
|
+
* body); {@link onRequest} replaces it once the page has fully rendered.
|
|
9
|
+
* Rendering the sprite from a component instead wouldn't work: Astro walks a
|
|
10
|
+
* template's own markup before its nested components render, so a sprite
|
|
11
|
+
* component at the end of the body runs before the header's search button or
|
|
12
|
+
* the page content's callouts have registered their icons.
|
|
13
|
+
*/
|
|
14
|
+
export const ICON_SPRITE_SLOT = "<!--blume-icon-sprite-->";
|
|
15
|
+
|
|
16
|
+
/** The page HTML with its placeholder replaced by the finished sprite. */
|
|
17
|
+
export const spliceIconSprite = (html: string, sprite?: IconSprite): string =>
|
|
18
|
+
html.includes(ICON_SPRITE_SLOT)
|
|
19
|
+
? html.replace(ICON_SPRITE_SLOT, sprite ? renderIconSprite(sprite) : "")
|
|
20
|
+
: html;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Blume's icon-sprite middleware (wired by the integration). Draining the
|
|
24
|
+
* response renders the whole page, after which the request's registry holds
|
|
25
|
+
* every icon it used; the sprite is spliced in where the shell left the slot.
|
|
26
|
+
* Requests whose shell kept no registry — a user layout override, a partial,
|
|
27
|
+
* an endpoint — pass through untouched, streaming and all.
|
|
28
|
+
*/
|
|
29
|
+
export const onRequest: MiddlewareHandler = async (context, render) => {
|
|
30
|
+
const response = await render();
|
|
31
|
+
const sprite = iconSpriteFor(context.locals);
|
|
32
|
+
if (!sprite) {
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
const html = await response.text();
|
|
36
|
+
return new Response(spliceIconSprite(html, sprite), {
|
|
37
|
+
headers: response.headers,
|
|
38
|
+
status: response.status,
|
|
39
|
+
statusText: response.statusText,
|
|
40
|
+
});
|
|
41
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A per-page SVG sprite for the icon sets. A page renders the same handful of
|
|
3
|
+
* icons many times over (every sidebar chevron, every code-block language
|
|
4
|
+
* icon, the page-action buttons); inlining each one repeats its paths on every
|
|
5
|
+
* use, which on a docs page is ~20 kB of the ~60 kB that isn't the sidebar.
|
|
6
|
+
* With the sprite, each use is a `<use href="#…">` reference and the paths
|
|
7
|
+
* appear once, in a hidden `<svg>` the layout renders at the end of the body.
|
|
8
|
+
*
|
|
9
|
+
* The registry lives on the request's `Astro.locals`, so every component in
|
|
10
|
+
* one page render — the layout, the MDX content, cached sidebar subtrees —
|
|
11
|
+
* shares it, and concurrent page renders never see each other's icons. A
|
|
12
|
+
* shell that creates no registry (a user's layout override, a partial) gets
|
|
13
|
+
* inline icons, exactly as before.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One symbol in the sprite: an icon set's inner markup and its viewBox. */
|
|
17
|
+
export interface IconSymbol {
|
|
18
|
+
body: string;
|
|
19
|
+
viewBox: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IconSprite {
|
|
23
|
+
/** Symbol id → symbol, in first-use order. */
|
|
24
|
+
symbols: Map<string, IconSymbol>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The request locals (`Astro.locals`) slot the registry lives in. */
|
|
28
|
+
export interface IconSpriteLocals {
|
|
29
|
+
blumeIconSprite?: IconSprite;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The request's sprite registry, when a shell created one. */
|
|
33
|
+
export const iconSpriteFor = (
|
|
34
|
+
locals: IconSpriteLocals
|
|
35
|
+
): IconSprite | undefined => locals.blumeIconSprite;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The request's sprite registry, created on first call: a shell calls this
|
|
39
|
+
* up front. Idempotent, so a shell rendered inside another shell's request
|
|
40
|
+
* joins the existing registry instead of discarding its icons.
|
|
41
|
+
*/
|
|
42
|
+
export const createIconSprite = (locals: IconSpriteLocals): IconSprite => {
|
|
43
|
+
locals.blumeIconSprite ??= { symbols: new Map() };
|
|
44
|
+
return locals.blumeIconSprite;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The sprite symbol id for an icon name. Names come from config and content
|
|
49
|
+
* (`lucide:arrow-right`, `simple-icons:github`), so anything outside the id
|
|
50
|
+
* charset is folded to a dash.
|
|
51
|
+
*/
|
|
52
|
+
export const iconSymbolId = (name: string): string =>
|
|
53
|
+
`blume-i-${name.toLowerCase().replaceAll(/[^a-z0-9-]+/gu, "-")}`;
|
|
54
|
+
|
|
55
|
+
/** Register a symbol (idempotent) and return its id. */
|
|
56
|
+
export const registerIconSymbol = (
|
|
57
|
+
sprite: IconSprite,
|
|
58
|
+
name: string,
|
|
59
|
+
symbol: IconSymbol
|
|
60
|
+
): string => {
|
|
61
|
+
const id = iconSymbolId(name);
|
|
62
|
+
if (!sprite.symbols.has(id)) {
|
|
63
|
+
sprite.symbols.set(id, symbol);
|
|
64
|
+
}
|
|
65
|
+
return id;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The ids of every sprite symbol a rendered HTML fragment references. */
|
|
69
|
+
export const referencedIconSymbols = (html: string): string[] => [
|
|
70
|
+
...new Set(
|
|
71
|
+
[...html.matchAll(/href="#(?<id>blume-i-[^"]+)"/gu)].map(
|
|
72
|
+
(match) => match.groups?.id ?? ""
|
|
73
|
+
)
|
|
74
|
+
),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The hidden sprite `<svg>` holding every registered symbol, or an empty
|
|
79
|
+
* string when the page used no set icon. Symbol bodies are the icon sets'
|
|
80
|
+
* own markup (already SVG); the viewBox is the only attribute interpolated.
|
|
81
|
+
*/
|
|
82
|
+
export const renderIconSprite = (sprite: IconSprite): string => {
|
|
83
|
+
if (sprite.symbols.size === 0) {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
const symbols = [...sprite.symbols]
|
|
87
|
+
.map(
|
|
88
|
+
([id, symbol]) =>
|
|
89
|
+
`<symbol id="${id}" viewBox="${symbol.viewBox}">${symbol.body}</symbol>`
|
|
90
|
+
)
|
|
91
|
+
.join("");
|
|
92
|
+
return `<svg aria-hidden="true" hidden xmlns="http://www.w3.org/2000/svg">${symbols}</svg>`;
|
|
93
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Where the page's icon sprite goes: every set icon the render registered
|
|
3
|
+
// (see icon-sprite.ts), as one hidden <svg> of <symbol>s the icons' <use>
|
|
4
|
+
// references resolve against. This only leaves a placeholder at the end of
|
|
5
|
+
// the body; the icon-sprite middleware replaces it once the page has fully
|
|
6
|
+
// rendered, since a component here would run before nested components
|
|
7
|
+
// (the header's search button, the content's callouts) register their icons.
|
|
8
|
+
import { ICON_SPRITE_SLOT } from "../icon-sprite-middleware.ts";
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<Fragment set:html={ICON_SPRITE_SLOT} />
|