blume 2.2.2 → 2.2.4
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 +18 -0
- package/THIRD-PARTY-NOTICES.md +51 -0
- package/dist/cli/{chunk-kmswjxj9.js → chunk-0b1a8xqz.js} +3 -3
- package/dist/cli/{chunk-rtvgmg2j.js → chunk-0qx6vwwf.js} +2 -2
- package/dist/cli/{chunk-qcyxkj44.js → chunk-3ps63r91.js} +43 -17
- package/dist/cli/chunk-3ps63r91.js.map +15 -0
- package/dist/cli/{chunk-b680sv30.js → chunk-402xaaxn.js} +10 -8
- package/dist/cli/chunk-402xaaxn.js.map +13 -0
- package/dist/cli/{chunk-k45sq16q.js → chunk-49xa4yf2.js} +39 -23
- package/dist/cli/{chunk-k45sq16q.js.map → chunk-49xa4yf2.js.map} +3 -3
- package/dist/cli/{chunk-j4nfgn2j.js → chunk-51wr1wre.js} +1 -1
- package/dist/cli/{chunk-9jqmq5hv.js → chunk-531qbwn1.js} +6 -6
- package/dist/cli/{chunk-r5nm9w0c.js → chunk-5bp65pv4.js} +107 -37
- package/dist/cli/{chunk-r5nm9w0c.js.map → chunk-5bp65pv4.js.map} +11 -10
- package/dist/cli/{chunk-yg8ckc3g.js → chunk-6nvcjzts.js} +2 -2
- package/dist/cli/{chunk-x7bh6d9j.js → chunk-6v5w8vzd.js} +4 -4
- package/dist/cli/{chunk-f260jdrn.js → chunk-8cshtem6.js} +5 -5
- package/dist/cli/{chunk-ydqzkhjn.js → chunk-8h5ceyk0.js} +6 -6
- package/dist/cli/{chunk-e1n41w9m.js → chunk-8v90n67z.js} +3 -3
- package/dist/cli/{chunk-etckvbrh.js → chunk-98me5yas.js} +14 -14
- package/dist/cli/{chunk-hktq4j5r.js → chunk-9k7gg4ga.js} +16 -10
- package/dist/cli/{chunk-hktq4j5r.js.map → chunk-9k7gg4ga.js.map} +5 -5
- package/dist/cli/{chunk-34wap3ed.js → chunk-9qmc57zw.js} +3 -3
- package/dist/cli/{chunk-beb0z983.js → chunk-a1xmzxk2.js} +16 -16
- package/dist/cli/{chunk-gwy55cx3.js → chunk-ajw85dgf.js} +2 -2
- package/dist/cli/{chunk-69rjf786.js → chunk-ansqj08e.js} +5 -5
- package/dist/cli/{chunk-q1wwrxzw.js → chunk-b0vwhmdf.js} +3 -3
- package/dist/cli/chunk-b0vwhmdf.js.map +10 -0
- package/dist/cli/{chunk-nqna8dzp.js → chunk-b5gztt4j.js} +17 -17
- package/dist/cli/{chunk-fnxgtjeq.js → chunk-bc4jz2m4.js} +1 -1
- package/dist/cli/{chunk-aafhwx74.js → chunk-d4h1y312.js} +1 -1
- package/dist/cli/{chunk-080mfch6.js → chunk-et7zwqkg.js} +15 -15
- package/dist/cli/{chunk-487b95qy.js → chunk-f29q3j13.js} +41 -25
- package/dist/cli/{chunk-487b95qy.js.map → chunk-f29q3j13.js.map} +3 -3
- package/dist/cli/{chunk-c5bjxq8v.js → chunk-f72388jn.js} +2 -2
- package/dist/cli/{chunk-t2h8pwy1.js → chunk-g0hpwbyx.js} +4 -4
- package/dist/cli/{chunk-40vk8kgf.js → chunk-j102xmnk.js} +7 -7
- package/dist/cli/{chunk-ppa6mg4d.js → chunk-m54bz9ng.js} +6 -6
- package/dist/cli/{chunk-h0qwdbem.js → chunk-mt6njy68.js} +2 -2
- package/dist/cli/{chunk-2ssh60qh.js → chunk-n091sgam.js} +6 -6
- package/dist/cli/{chunk-1ch3ct3r.js → chunk-p33bf2nb.js} +14 -14
- package/dist/cli/{chunk-4zxh25hc.js → chunk-saxq5jjr.js} +2 -2
- package/dist/cli/{chunk-x60e15st.js → chunk-sbt1s5j2.js} +17 -17
- package/dist/cli/{chunk-gxhcf2hs.js → chunk-tckd6qwv.js} +2 -2
- package/dist/cli/{chunk-p834rkcn.js → chunk-tf4134kk.js} +5 -5
- package/dist/cli/{chunk-zxd5jrgx.js → chunk-vxdjss7f.js} +76 -34
- package/dist/cli/{chunk-zxd5jrgx.js.map → chunk-vxdjss7f.js.map} +4 -4
- package/dist/cli/{chunk-9h8mh2tk.js → chunk-x2pgg22t.js} +1 -1
- package/dist/cli/{chunk-hn3g2b89.js → chunk-xhrwrzrh.js} +9 -9
- package/dist/cli/index.js +18 -18
- package/dist/types/ai/ask-context.d.ts +8 -0
- package/dist/types/ai/link-text.d.ts +5 -0
- package/dist/types/ai/relative-links.d.ts +26 -6
- package/dist/types/core/config-input.d.ts +41 -1
- package/dist/types/core/data.d.ts +14 -0
- package/dist/types/core/last-modified.d.ts +33 -18
- package/dist/types/core/links.d.ts +16 -0
- package/dist/types/core/schema.d.ts +17 -1
- package/dist/types/core/types.d.ts +10 -0
- package/dist/types/deploy/sitemap.d.ts +9 -0
- package/dist/types/search/documents.d.ts +3 -1
- package/dist/types/search/fold.d.ts +33 -0
- package/dist/types/search/orama-index.d.ts +4 -4
- package/dist/types/seo/jsonld.d.ts +33 -0
- package/docs/configuration/search.mdx +2 -2
- package/docs/configuration/theming.mdx +13 -1
- package/docs/content/frontmatter.mdx +1 -1
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/sources/remote-mdx.mdx +1 -1
- package/docs/discoverability/open-graph.mdx +16 -1
- package/docs/discoverability/sitemap-and-robots.mdx +12 -0
- package/docs/discoverability/structured-data.mdx +44 -0
- package/package.json +4 -3
- package/skills/blume-migrate/references/docus.md +1 -2
- package/skills/blume-migrate/references/docusaurus.md +1 -1
- package/skills/blume-migrate/references/vitepress.md +2 -2
- package/skills/blume-migrate/references/vuepress.md +1 -1
- package/src/ai/ask-context.ts +52 -14
- package/src/ai/ask-data.ts +4 -1
- package/src/ai/ask-tools.ts +17 -5
- package/src/ai/link-text.ts +6 -0
- package/src/ai/llms.ts +1 -7
- package/src/ai/markdown.ts +8 -4
- package/src/ai/relative-links.ts +155 -48
- package/src/ai/site-skill.ts +1 -4
- package/src/astro/generate.ts +7 -0
- package/src/astro/templates.ts +36 -20
- package/src/components/layout/Header.astro +18 -10
- package/src/components/layout/PageActions.astro +1 -0
- package/src/components/layout/PageLayout.astro +31 -13
- package/src/components/layout/ReferenceLayout.astro +6 -1
- package/src/components/layout/RootLayout.astro +13 -1
- package/src/components/layout/head-scripts.ts +5 -1
- package/src/components/layout/locale-redirect.ts +38 -19
- package/src/components/layout/search/types.ts +12 -4
- package/src/core/config-input.ts +40 -1
- package/src/core/config.ts +5 -2
- package/src/core/data.ts +14 -0
- package/src/core/last-modified.ts +80 -34
- package/src/core/links.ts +43 -2
- package/src/core/manifest.ts +2 -0
- package/src/core/project-graph.ts +83 -43
- package/src/core/schema.ts +48 -9
- package/src/core/types.ts +10 -0
- package/src/deploy/sitemap.ts +127 -49
- package/src/search/documents.ts +34 -11
- package/src/search/fold.ts +175 -0
- package/src/search/orama-index.ts +40 -49
- package/src/seo/jsonld.ts +100 -23
- package/src/theme/chrome-icons.ts +2 -1
- package/src/theme/entry.ts +4 -1
- package/dist/cli/chunk-b680sv30.js.map +0 -13
- package/dist/cli/chunk-q1wwrxzw.js.map +0 -10
- package/dist/cli/chunk-qcyxkj44.js.map +0 -14
- /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-0b1a8xqz.js.map} +0 -0
- /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-0qx6vwwf.js.map} +0 -0
- /package/dist/cli/{chunk-j4nfgn2j.js.map → chunk-51wr1wre.js.map} +0 -0
- /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-531qbwn1.js.map} +0 -0
- /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-6nvcjzts.js.map} +0 -0
- /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6v5w8vzd.js.map} +0 -0
- /package/dist/cli/{chunk-f260jdrn.js.map → chunk-8cshtem6.js.map} +0 -0
- /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-8h5ceyk0.js.map} +0 -0
- /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-8v90n67z.js.map} +0 -0
- /package/dist/cli/{chunk-etckvbrh.js.map → chunk-98me5yas.js.map} +0 -0
- /package/dist/cli/{chunk-34wap3ed.js.map → chunk-9qmc57zw.js.map} +0 -0
- /package/dist/cli/{chunk-beb0z983.js.map → chunk-a1xmzxk2.js.map} +0 -0
- /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-ajw85dgf.js.map} +0 -0
- /package/dist/cli/{chunk-69rjf786.js.map → chunk-ansqj08e.js.map} +0 -0
- /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-b5gztt4j.js.map} +0 -0
- /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-bc4jz2m4.js.map} +0 -0
- /package/dist/cli/{chunk-aafhwx74.js.map → chunk-d4h1y312.js.map} +0 -0
- /package/dist/cli/{chunk-080mfch6.js.map → chunk-et7zwqkg.js.map} +0 -0
- /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-f72388jn.js.map} +0 -0
- /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-g0hpwbyx.js.map} +0 -0
- /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-j102xmnk.js.map} +0 -0
- /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-m54bz9ng.js.map} +0 -0
- /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-mt6njy68.js.map} +0 -0
- /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-n091sgam.js.map} +0 -0
- /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-p33bf2nb.js.map} +0 -0
- /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-saxq5jjr.js.map} +0 -0
- /package/dist/cli/{chunk-x60e15st.js.map → chunk-sbt1s5j2.js.map} +0 -0
- /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-tckd6qwv.js.map} +0 -0
- /package/dist/cli/{chunk-p834rkcn.js.map → chunk-tf4134kk.js.map} +0 -0
- /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-x2pgg22t.js.map} +0 -0
- /package/dist/cli/{chunk-hn3g2b89.js.map → chunk-xhrwrzrh.js.map} +0 -0
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
OG_IMAGE_WIDTH,
|
|
44
44
|
} from "../../og/dimensions.ts";
|
|
45
45
|
import { buildStructuredData, toIso } from "../../seo/jsonld.ts";
|
|
46
|
+
import type { AuthorInput } from "../../seo/jsonld.ts";
|
|
46
47
|
import { normalizeXHandle } from "../../seo/x-handle.ts";
|
|
47
48
|
import { withBase, withMountedBase } from "../islands/base-path.ts";
|
|
48
49
|
import { routeSetFor } from "../../core/locale-links.ts";
|
|
@@ -196,6 +197,8 @@ interface Props {
|
|
|
196
197
|
/** The page's `mode` frontmatter: what it shows around its content (see `core/page-modes.ts`). */
|
|
197
198
|
pageMode?: PageMode;
|
|
198
199
|
published?: string | Date | null;
|
|
200
|
+
/** The page's front matter `authors`, credited in its JSON-LD. */
|
|
201
|
+
authors?: AuthorInput | AuthorInput[];
|
|
199
202
|
lastModified?: string | null;
|
|
200
203
|
noindex?: boolean;
|
|
201
204
|
structuredDataEnabled?: boolean;
|
|
@@ -312,6 +315,7 @@ const {
|
|
|
312
315
|
apiRail = false,
|
|
313
316
|
pageMode,
|
|
314
317
|
published,
|
|
318
|
+
authors,
|
|
315
319
|
lastModified,
|
|
316
320
|
noindex,
|
|
317
321
|
structuredDataEnabled,
|
|
@@ -534,11 +538,14 @@ const structuredData =
|
|
|
534
538
|
structuredDataEnabled === false || noindex
|
|
535
539
|
? null
|
|
536
540
|
: buildStructuredData({
|
|
541
|
+
authors,
|
|
537
542
|
base: import.meta.env.BASE_URL,
|
|
538
543
|
breadcrumbs: crumbs,
|
|
539
544
|
description,
|
|
540
545
|
homeRoute: navigation.root,
|
|
541
546
|
identity: data.config.identity,
|
|
547
|
+
jsonLd: data.config.jsonLd,
|
|
548
|
+
image: ogImage,
|
|
542
549
|
locale,
|
|
543
550
|
modified: lastModified,
|
|
544
551
|
pageType,
|
|
@@ -685,7 +692,12 @@ const localeRedirect = localeTargets
|
|
|
685
692
|
<script data-key={bannerKey} is:inline set:html={BANNER_INIT_SCRIPT} />
|
|
686
693
|
)
|
|
687
694
|
}
|
|
688
|
-
<script
|
|
695
|
+
<script
|
|
696
|
+
data-fixed={data.config.theme.toggle ? undefined : ""}
|
|
697
|
+
data-mode={themeMode}
|
|
698
|
+
is:inline
|
|
699
|
+
set:html={THEME_INIT_SCRIPT}
|
|
700
|
+
/>
|
|
689
701
|
{consent && <ConsentHead consent={consent} />}
|
|
690
702
|
<Analytics analytics={analytics} />
|
|
691
703
|
</head>
|
|
@@ -45,12 +45,16 @@
|
|
|
45
45
|
* after a swap the theme the `before-swap` stamp carried over stands, so a
|
|
46
46
|
* toggle the reader made still holds for the rest of their visit.
|
|
47
47
|
*
|
|
48
|
+
* `data-fixed` (present when `theme.toggle` is off) skips the stored
|
|
49
|
+
* preference, so a choice saved before the toggle was removed can't hold the
|
|
50
|
+
* page in a mode the site no longer offers.
|
|
51
|
+
*
|
|
48
52
|
* Paper is white, so a dark page prints light: `beforeprint` switches the root
|
|
49
53
|
* to the light theme (every token, Shiki color, and `dark:` utility follows
|
|
50
54
|
* it) and `afterprint` puts the dark theme back. The listeners sit on
|
|
51
55
|
* `window`, so the one registration outlives client-router swaps.
|
|
52
56
|
*/
|
|
53
|
-
export const THEME_INIT_SCRIPT = `(()=>{const
|
|
57
|
+
export const THEME_INIT_SCRIPT = `(()=>{const ds=document.currentScript?.dataset;const m=ds?.mode??"system";const fixed=ds?.fixed!==undefined;const apply=()=>{const r=document.documentElement;let s=null;let blocked=false;try{s=fixed?null:localStorage.getItem("blume-theme");}catch{blocked=true;}const sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";r.dataset.theme=s??(blocked&&r.dataset.theme?r.dataset.theme:m==="system"?sys:m);};apply();document.addEventListener("astro:before-swap",(e)=>{const t=document.documentElement.dataset.theme;if(t){e.newDocument.documentElement.dataset.theme=t;}});document.addEventListener("astro:after-swap",apply);let printed=null;addEventListener("beforeprint",()=>{const r=document.documentElement;if(r.dataset.theme==="dark"){printed="dark";r.dataset.theme="light";}});addEventListener("afterprint",()=>{if(printed){document.documentElement.dataset.theme=printed;printed=null;}});})();`;
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
60
|
* Keep an embedded Scalar reference on Blume's theme. Scalar picks its own
|
|
@@ -65,6 +65,7 @@ export const LOCALE_REDIRECT_SCRIPT = `(()=>{const s=document.currentScript;if(!
|
|
|
65
65
|
interface ClosestTarget {
|
|
66
66
|
closest: (selector: string) => {
|
|
67
67
|
getAttribute: (name: string) => string | null;
|
|
68
|
+
setAttribute: (name: string, value: string) => void;
|
|
68
69
|
} | null;
|
|
69
70
|
}
|
|
70
71
|
|
|
@@ -75,33 +76,51 @@ interface Click {
|
|
|
75
76
|
|
|
76
77
|
/** What {@link rememberLocaleChoice} listens on: the document. */
|
|
77
78
|
export interface ChoiceTarget {
|
|
78
|
-
addEventListener: (
|
|
79
|
+
addEventListener: (
|
|
80
|
+
type: "click",
|
|
81
|
+
listener: (event: Click) => void,
|
|
82
|
+
options: { capture: true }
|
|
83
|
+
) => void;
|
|
84
|
+
location: { readonly hash: string };
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
const hasClosest = (target: Click["target"]): target is ClosestTarget =>
|
|
82
88
|
target !== null && "closest" in target;
|
|
83
89
|
|
|
84
|
-
/** The
|
|
85
|
-
const
|
|
86
|
-
hasClosest(target)
|
|
87
|
-
? target.closest("a[data-blume-locale-choice]")?.getAttribute("hreflang")
|
|
88
|
-
: null;
|
|
90
|
+
/** The switcher link a click landed on, if any. */
|
|
91
|
+
const chosenLink = (target: Click["target"]) =>
|
|
92
|
+
hasClosest(target) ? target.closest("a[data-blume-locale-choice]") : null;
|
|
89
93
|
|
|
90
94
|
/**
|
|
91
95
|
* Remember a language picked with the switcher, so routing by browser
|
|
92
|
-
* language stops sending the reader elsewhere
|
|
93
|
-
*
|
|
96
|
+
* language stops sending the reader elsewhere, and keep the reader's place:
|
|
97
|
+
* the chosen link carries the current fragment (a heading the translation
|
|
98
|
+
* lacks leaves the reader at the top). One listener on the document covers
|
|
99
|
+
* every switcher, on every page the client router swaps in. It listens in
|
|
100
|
+
* the capture phase: the client router's own listener on the document reads
|
|
101
|
+
* the link's href and navigates, and it's registered first.
|
|
94
102
|
*/
|
|
95
103
|
export const rememberLocaleChoice = (target: ChoiceTarget): void => {
|
|
96
|
-
target.addEventListener(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
target.addEventListener(
|
|
105
|
+
"click",
|
|
106
|
+
(event) => {
|
|
107
|
+
const link = chosenLink(event.target);
|
|
108
|
+
const code = link?.getAttribute("hreflang");
|
|
109
|
+
if (!(link && code)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
// Rewritten even without a fragment, so one left by an earlier click
|
|
113
|
+
// (one that opened a new tab) doesn't stick.
|
|
114
|
+
const href = link.getAttribute("href");
|
|
115
|
+
if (href !== null) {
|
|
116
|
+
link.setAttribute("href", href.split("#")[0] + target.location.hash);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
localStorage.setItem(LOCALE_STORAGE_KEY, code);
|
|
120
|
+
} catch {
|
|
121
|
+
// Storage can be off; routing then stays off too (see the script).
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{ capture: true }
|
|
125
|
+
);
|
|
107
126
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { escape } from "html-escaper";
|
|
2
2
|
|
|
3
|
+
import { accentInsensitive } from "../../../search/fold.ts";
|
|
4
|
+
|
|
3
5
|
/** A single result rendered in the search dialog. */
|
|
4
6
|
export interface SearchHit {
|
|
5
7
|
url: string;
|
|
@@ -96,16 +98,19 @@ export const SEARCH_LIMIT = 12;
|
|
|
96
98
|
*/
|
|
97
99
|
export const RESULT_POOL = 48;
|
|
98
100
|
|
|
99
|
-
const REGEXP_SPECIAL = /[$()*+.?[\\\]^{|}]/gu;
|
|
100
101
|
const WORD_BREAK = /\s+/u;
|
|
101
102
|
|
|
102
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Split a query into non-empty search tokens, each a regex source that
|
|
105
|
+
* matches it whatever accents the text carries: the index folds Prüfung to
|
|
106
|
+
* prufung, so a page found by `prufung` is marked and excerpted there too.
|
|
107
|
+
*/
|
|
103
108
|
const queryTokens = (query: string): string[] =>
|
|
104
109
|
query
|
|
105
110
|
.trim()
|
|
106
111
|
.split(WORD_BREAK)
|
|
107
112
|
.filter(Boolean)
|
|
108
|
-
.map((token) => token
|
|
113
|
+
.map((token) => accentInsensitive(token));
|
|
109
114
|
|
|
110
115
|
/**
|
|
111
116
|
* Wrap query matches in `<mark>`, HTML-escaping the source text. Matching runs
|
|
@@ -159,7 +164,10 @@ export const sanitizeExcerpt = (html: string): string =>
|
|
|
159
164
|
})
|
|
160
165
|
.join("");
|
|
161
166
|
|
|
162
|
-
/**
|
|
167
|
+
/**
|
|
168
|
+
* First index in `text` where any query token matches, ignoring case and
|
|
169
|
+
* accents.
|
|
170
|
+
*/
|
|
163
171
|
const matchIndex = (text: string, query: string): number => {
|
|
164
172
|
const tokens = queryTokens(query);
|
|
165
173
|
if (tokens.length === 0) {
|
package/src/core/config-input.ts
CHANGED
|
@@ -500,6 +500,12 @@ export interface ThemeConfig {
|
|
|
500
500
|
mode?: "system" | "light" | "dark";
|
|
501
501
|
/** Corner radius scale. Defaults to `md`. */
|
|
502
502
|
radius?: RadiusScale;
|
|
503
|
+
/**
|
|
504
|
+
* Show the header's light/dark toggle. Defaults to `true`. `false` hides it
|
|
505
|
+
* and ignores any choice a reader saved earlier, so every page renders in
|
|
506
|
+
* `mode` (`system` follows the reader's OS setting).
|
|
507
|
+
*/
|
|
508
|
+
toggle?: boolean;
|
|
503
509
|
}
|
|
504
510
|
|
|
505
511
|
// ---------------------------------------------------------------------------
|
|
@@ -1099,6 +1105,12 @@ export interface OgPaletteConfig {
|
|
|
1099
1105
|
|
|
1100
1106
|
/** Per-page Open Graph image generation. */
|
|
1101
1107
|
export interface OgConfig {
|
|
1108
|
+
/**
|
|
1109
|
+
* Default social image for pages without their own `seo.image`, in place of
|
|
1110
|
+
* the generated card (which then defaults off): one path or URL, or one per
|
|
1111
|
+
* locale, like any localizable label.
|
|
1112
|
+
*/
|
|
1113
|
+
image?: string | Record<string, string>;
|
|
1102
1114
|
/**
|
|
1103
1115
|
* Card subtitle. Defaults to the site description; a string overrides it,
|
|
1104
1116
|
* `false` renders the card without one.
|
|
@@ -1223,6 +1235,28 @@ export interface SoftwareConfig {
|
|
|
1223
1235
|
|
|
1224
1236
|
/** Discoverability: OG images, feeds, sitemap, robots, and structured data. */
|
|
1225
1237
|
export interface SeoConfig {
|
|
1238
|
+
/**
|
|
1239
|
+
* Structured-data entities shared with a parent site (`example.com/docs`),
|
|
1240
|
+
* referenced by absolute `@id` instead of defined again.
|
|
1241
|
+
*/
|
|
1242
|
+
jsonLd?: {
|
|
1243
|
+
/** The WebSite's `@id`, which every page names as `isPartOf`. */
|
|
1244
|
+
websiteId?: string;
|
|
1245
|
+
/** The Organization's `@id`; `organization` still adds its details. */
|
|
1246
|
+
organizationId?: string;
|
|
1247
|
+
/** Credited on pages without their own `authors`. */
|
|
1248
|
+
author?: { "@id": string };
|
|
1249
|
+
/** Every page's publisher, over the Organization. */
|
|
1250
|
+
publisher?: { "@id": string };
|
|
1251
|
+
/** Emit the WebSite node. Defaults to true unless `websiteId` is set. */
|
|
1252
|
+
website?: boolean;
|
|
1253
|
+
};
|
|
1254
|
+
/**
|
|
1255
|
+
* Date pages without a front matter `date` by the git commit that added
|
|
1256
|
+
* their source file, following exact renames. Defaults to `false`.
|
|
1257
|
+
*/
|
|
1258
|
+
datePublished?: false | "git";
|
|
1259
|
+
|
|
1226
1260
|
/**
|
|
1227
1261
|
* Meta tags written into every page's head, name to content: site
|
|
1228
1262
|
* verification tokens, `theme-color`, an app banner, and anything else
|
|
@@ -1262,7 +1296,12 @@ export interface SeoConfig {
|
|
|
1262
1296
|
/** RSS/Atom feeds. */
|
|
1263
1297
|
rss?: RssConfig;
|
|
1264
1298
|
/** Generate sitemap.xml (requires `deployment.site`). Defaults to `true`. */
|
|
1265
|
-
sitemap?:
|
|
1299
|
+
sitemap?:
|
|
1300
|
+
| boolean
|
|
1301
|
+
| {
|
|
1302
|
+
/** List each page's translations as hreflang links. Defaults to `false`. */
|
|
1303
|
+
alternates?: boolean;
|
|
1304
|
+
};
|
|
1266
1305
|
/**
|
|
1267
1306
|
* The documented product, added to the homepage's JSON-LD as a
|
|
1268
1307
|
* `SoftwareApplication` node. `true` takes every default (name and
|
package/src/core/config.ts
CHANGED
|
@@ -394,8 +394,11 @@ export const loadConfig = async (
|
|
|
394
394
|
const site = config.deployment.options.site ?? options.devServerUrl;
|
|
395
395
|
|
|
396
396
|
// OG images need an absolute `og:image`, so they default on once a site URL
|
|
397
|
-
// is known and off otherwise
|
|
398
|
-
|
|
397
|
+
// is known and off otherwise — and off beside a default `seo.og.image`,
|
|
398
|
+
// which every page shows instead of its card. An explicit `seo.og.enabled`
|
|
399
|
+
// always wins.
|
|
400
|
+
const ogEnabled =
|
|
401
|
+
config.seo.og.enabled ?? (Boolean(site) && !config.seo.og.image);
|
|
399
402
|
|
|
400
403
|
return {
|
|
401
404
|
config: {
|
package/src/core/data.ts
CHANGED
|
@@ -100,6 +100,12 @@ export interface BlumeRoute {
|
|
|
100
100
|
/** True when this route renders fallback content for a missing translation. */
|
|
101
101
|
fallback: boolean;
|
|
102
102
|
hidden: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* The translations the page head links as hreflang alternates: the
|
|
105
|
+
* indexable ones, so a `noindex` or hidden translation is left out (see
|
|
106
|
+
* `hreflangAlternates`).
|
|
107
|
+
*/
|
|
108
|
+
hreflang: RouteAlternate[];
|
|
103
109
|
id: string;
|
|
104
110
|
/** Whether the page is part of the search index. */
|
|
105
111
|
indexable: boolean;
|
|
@@ -113,6 +119,12 @@ export interface BlumeRoute {
|
|
|
113
119
|
*/
|
|
114
120
|
monolingual: boolean;
|
|
115
121
|
path: string;
|
|
122
|
+
/**
|
|
123
|
+
* The git commit that added the page's source, as an ISO date, when
|
|
124
|
+
* `seo.datePublished` is `"git"` and front matter carries no date; else
|
|
125
|
+
* `null`.
|
|
126
|
+
*/
|
|
127
|
+
published: string | null;
|
|
116
128
|
title: string;
|
|
117
129
|
/** Resolved docs version (`""` for the current docs). */
|
|
118
130
|
version: string;
|
|
@@ -229,6 +241,7 @@ export interface BlumeDataConfig {
|
|
|
229
241
|
* kept out of this snapshot, which pages serialize into HTML.
|
|
230
242
|
*/
|
|
231
243
|
og: {
|
|
244
|
+
image?: string | Record<string, string>;
|
|
232
245
|
/**
|
|
233
246
|
* Site-wide card subtitle: `seo.og.description` (`false` omits it) over
|
|
234
247
|
* the site description. A page with its own description shows that
|
|
@@ -265,6 +278,7 @@ export interface BlumeDataConfig {
|
|
|
265
278
|
};
|
|
266
279
|
/** Deployment site URL, or `null` when none is configured/detected. */
|
|
267
280
|
site: string | null;
|
|
281
|
+
jsonLd: ResolvedConfig["seo"]["jsonLd"];
|
|
268
282
|
structuredData: boolean;
|
|
269
283
|
theme: ResolvedConfig["theme"];
|
|
270
284
|
title: string;
|
|
@@ -40,27 +40,41 @@ export const resolveLastModifiedConfig = (
|
|
|
40
40
|
? { enabled: false, source: "git" }
|
|
41
41
|
: { enabled: true, source: value };
|
|
42
42
|
|
|
43
|
+
/** Each file's git dates, keyed by path. */
|
|
44
|
+
export interface GitDates {
|
|
45
|
+
/** The committer ISO date of the newest commit that changed the file. */
|
|
46
|
+
modified: Map<string, string>;
|
|
47
|
+
/** The committer ISO date of the commit that added the file. */
|
|
48
|
+
published: Map<string, string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
/**
|
|
44
|
-
* Parse `git log --format=%x00%cI --name-status -M100%` output into
|
|
45
|
-
* repo-root-relative path
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* ignored.
|
|
52
|
+
* Parse `git log --format=%x00%cI --name-status -M100%` output into each
|
|
53
|
+
* repo-root-relative path's dates. Each commit emits a NUL-prefixed date line
|
|
54
|
+
* followed by a status line per path it touched (`M\tpath`, `R100\told\tnew`);
|
|
55
|
+
* since git logs newest-first, the first date seen for a path is when it last
|
|
56
|
+
* changed, and the add (`A\tpath`) that ends its history is when it was
|
|
57
|
+
* published. Blank lines are ignored.
|
|
50
58
|
*
|
|
51
59
|
* A rename (`-M100%` reports only exact ones, where the content is unchanged)
|
|
52
60
|
* follows the file the way `git log --follow` does for one path: it dates
|
|
53
61
|
* nothing itself, and the older commits that name the old path date the file
|
|
54
62
|
* under its current name, so renaming `page.md` to `page.mdx` keeps the page's
|
|
55
|
-
*
|
|
56
|
-
* off the commits before it) takes the rename's date.
|
|
63
|
+
* dates. A file whose visible history starts at a rename (a shallow clone cut
|
|
64
|
+
* off the commits before it) takes the rename's date. Only exact renames are
|
|
65
|
+
* followed: `--follow`'s default 50% similarity would hand a new page the
|
|
66
|
+
* history of a similar one deleted in the same commit.
|
|
57
67
|
*/
|
|
58
|
-
export const parseGitLog = (output: string):
|
|
59
|
-
const
|
|
68
|
+
export const parseGitLog = (output: string): GitDates => {
|
|
69
|
+
const modified = new Map<string, string>();
|
|
70
|
+
const published = new Map<string, string>();
|
|
60
71
|
// An older name of a file → its name now, for the commits read after the
|
|
61
72
|
// rename (older ones).
|
|
62
73
|
const currentNames = new Map<string, string>();
|
|
63
74
|
const renamedAt = new Map<string, string>();
|
|
75
|
+
// Files whose add has been read: anything older under the same name was an
|
|
76
|
+
// earlier file at that path, deleted before this one was created.
|
|
77
|
+
const added = new Set<string>();
|
|
64
78
|
let current: string | null = null;
|
|
65
79
|
for (const line of output.split("\n")) {
|
|
66
80
|
if (line.startsWith("\0")) {
|
|
@@ -73,21 +87,27 @@ export const parseGitLog = (output: string): Map<string, string> => {
|
|
|
73
87
|
continue;
|
|
74
88
|
}
|
|
75
89
|
const name = currentNames.get(path) ?? path;
|
|
90
|
+
if (!added.has(name)) {
|
|
91
|
+
published.set(name, current);
|
|
92
|
+
if (status === "A") {
|
|
93
|
+
added.add(name);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
76
96
|
if (status.startsWith("R")) {
|
|
77
97
|
currentNames.set(from, name);
|
|
78
98
|
if (!renamedAt.has(name)) {
|
|
79
99
|
renamedAt.set(name, current);
|
|
80
100
|
}
|
|
81
|
-
} else if (!
|
|
82
|
-
|
|
101
|
+
} else if (!modified.has(name)) {
|
|
102
|
+
modified.set(name, current);
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
105
|
for (const [name, date] of renamedAt) {
|
|
86
|
-
if (!
|
|
87
|
-
|
|
106
|
+
if (!modified.has(name)) {
|
|
107
|
+
modified.set(name, date);
|
|
88
108
|
}
|
|
89
109
|
}
|
|
90
|
-
return
|
|
110
|
+
return { modified, published };
|
|
91
111
|
};
|
|
92
112
|
|
|
93
113
|
/**
|
|
@@ -127,37 +147,38 @@ export const gitRepositoryRoot = (root: string): string | null => {
|
|
|
127
147
|
}
|
|
128
148
|
};
|
|
129
149
|
|
|
150
|
+
const noDates = (): GitDates => ({ modified: new Map(), published: new Map() });
|
|
151
|
+
|
|
130
152
|
/**
|
|
131
|
-
* Resolve each source file's
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* is unavailable or the project isn't a repo — the feature then simply shows
|
|
153
|
+
* Resolve each source file's git dates (see `parseGitLog`), keyed by absolute
|
|
154
|
+
* source path. Runs a single `git log` over the given content roots (each
|
|
155
|
+
* local source's own root, which may diverge from `content.root`), following
|
|
156
|
+
* renames within them (a file moved in from outside every root dates from the
|
|
157
|
+
* move), and maps repo-root-relative paths back to the given absolute paths
|
|
158
|
+
* (monorepo-safe via `rev-parse --show-toplevel`). Returns empty maps if git
|
|
159
|
+
* is unavailable or the project isn't a repo — the features then simply show
|
|
139
160
|
* no dates.
|
|
140
161
|
*/
|
|
141
|
-
export const
|
|
162
|
+
export const gitFileDates = (
|
|
142
163
|
root: string,
|
|
143
164
|
contentRoots: string[],
|
|
144
165
|
sourcePaths: string[],
|
|
145
166
|
repositoryRoot?: string | null
|
|
146
|
-
):
|
|
167
|
+
): GitDates => {
|
|
147
168
|
// Nothing to date, or nowhere bounded to look — either way, don't pay for a
|
|
148
169
|
// git scan. Both guards matter: `git log -- ` with no pathspec logs the
|
|
149
170
|
// entire repository, which is what an all-staged project produces (a staged
|
|
150
171
|
// source contributes no content root, yet its entries can still carry a
|
|
151
172
|
// `sourcePath`).
|
|
152
173
|
if (sourcePaths.length === 0 || contentRoots.length === 0) {
|
|
153
|
-
return
|
|
174
|
+
return noDates();
|
|
154
175
|
}
|
|
155
176
|
// The caller that bounded `contentRoots` already resolved the repo root;
|
|
156
177
|
// reuse it rather than spawning `rev-parse` a second time per scan.
|
|
157
178
|
const gitRoot =
|
|
158
179
|
repositoryRoot === undefined ? gitRepositoryRoot(root) : repositoryRoot;
|
|
159
180
|
if (gitRoot === null) {
|
|
160
|
-
return
|
|
181
|
+
return noDates();
|
|
161
182
|
}
|
|
162
183
|
// Git names paths by their real location, so the pathspecs and the paths
|
|
163
184
|
// read back against the log are compared in that spelling too (see
|
|
@@ -185,16 +206,19 @@ export const gitLastModifiedTimes = (
|
|
|
185
206
|
{ encoding: "utf-8", env: gitEnv(), maxBuffer: 256 * 1024 * 1024 }
|
|
186
207
|
);
|
|
187
208
|
const byRepoPath = parseGitLog(output);
|
|
188
|
-
const result =
|
|
209
|
+
const result = noDates();
|
|
189
210
|
for (const sourcePath of sourcePaths) {
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
const repoPath = relative(top, realPath(sourcePath));
|
|
212
|
+
for (const key of ["modified", "published"] as const) {
|
|
213
|
+
const iso = byRepoPath[key].get(repoPath);
|
|
214
|
+
if (iso) {
|
|
215
|
+
result[key].set(sourcePath, iso);
|
|
216
|
+
}
|
|
193
217
|
}
|
|
194
218
|
}
|
|
195
219
|
return result;
|
|
196
220
|
} catch {
|
|
197
|
-
return
|
|
221
|
+
return noDates();
|
|
198
222
|
}
|
|
199
223
|
};
|
|
200
224
|
|
|
@@ -223,6 +247,9 @@ export const isShallowGitRepository = (root: string): boolean => {
|
|
|
223
247
|
}
|
|
224
248
|
};
|
|
225
249
|
|
|
250
|
+
const SHALLOW_CLONE_SUGGESTION =
|
|
251
|
+
"Fetch full history in CI: set the VERCEL_DEEP_CLONE=true environment variable on Vercel, or fetch-depth: 0 for actions/checkout.";
|
|
252
|
+
|
|
226
253
|
/**
|
|
227
254
|
* A warning for git-derived dates silently missing because the build ran in a
|
|
228
255
|
* shallow clone — the default on Vercel and `actions/checkout`, where `git log`
|
|
@@ -243,8 +270,27 @@ export const lastModifiedShallowWarning = (
|
|
|
243
270
|
code: "BLUME_SHALLOW_GIT_HISTORY",
|
|
244
271
|
message: `lastModified is on, but this build runs in a shallow git clone, so ${undatedCount} page(s) have no git-derived date — their sitemap <lastmod> and "Last updated" stamps are omitted.`,
|
|
245
272
|
severity: "warning",
|
|
246
|
-
suggestion:
|
|
247
|
-
"Fetch full history in CI: set the VERCEL_DEEP_CLONE=true environment variable on Vercel, or fetch-depth: 0 for actions/checkout.",
|
|
273
|
+
suggestion: SHALLOW_CLONE_SUGGESTION,
|
|
248
274
|
},
|
|
249
275
|
];
|
|
250
276
|
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The warning for `seo.datePublished: "git"` in a shallow clone, where the
|
|
280
|
+
* oldest commit git can see is where the clone was cut, not where each page
|
|
281
|
+
* began. Git then dates no page rather than every page wrongly. Empty when
|
|
282
|
+
* every page has a front matter date.
|
|
283
|
+
*/
|
|
284
|
+
export const datePublishedShallowWarning = (
|
|
285
|
+
undatedCount: number
|
|
286
|
+
): Diagnostic[] =>
|
|
287
|
+
undatedCount === 0
|
|
288
|
+
? []
|
|
289
|
+
: [
|
|
290
|
+
{
|
|
291
|
+
code: "BLUME_SHALLOW_GIT_HISTORY",
|
|
292
|
+
message: `seo.datePublished is "git", but this build runs in a shallow git clone, so ${undatedCount} page(s) without a front matter date have no datePublished.`,
|
|
293
|
+
severity: "warning",
|
|
294
|
+
suggestion: SHALLOW_CLONE_SUGGESTION,
|
|
295
|
+
},
|
|
296
|
+
];
|
package/src/core/links.ts
CHANGED
|
@@ -249,6 +249,36 @@ export const resolveRelativeHref = (
|
|
|
249
249
|
return `${fileRoute ?? toRoute(resolveRelative(from.route, path, from.isIndex))}${suffix}`;
|
|
250
250
|
};
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Where a relative page link in a raw HTML `<a href>` written on `route`
|
|
254
|
+
* lands, with the authored `?query#hash` kept, or `undefined` when `href`
|
|
255
|
+
* isn't a relative page link (see {@link resolveRelativeHref}). The Markdown
|
|
256
|
+
* pipeline leaves a raw element as written, so the browser resolves it
|
|
257
|
+
* against the page's slashless URL, as `blume validate` reads it: its parent
|
|
258
|
+
* directory, even on an index page, and a `.md` target is that path's
|
|
259
|
+
* Markdown copy rather than a file link.
|
|
260
|
+
*/
|
|
261
|
+
export const resolveRawRelativeHref = (
|
|
262
|
+
href: string,
|
|
263
|
+
route: string,
|
|
264
|
+
hasRoute: (route: string) => boolean
|
|
265
|
+
): string | undefined => {
|
|
266
|
+
if (href === "" || NOT_RELATIVE.test(href)) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
const suffixAt = href.search(/[?#]/u);
|
|
270
|
+
const path = suffixAt === -1 ? href : href.slice(0, suffixAt);
|
|
271
|
+
const resolved = resolveRelative(route, path, false);
|
|
272
|
+
if (
|
|
273
|
+
FILE_EXT.test(path) &&
|
|
274
|
+
!DOC_EXT.test(path) &&
|
|
275
|
+
!hasRoute(decodePercent(resolved))
|
|
276
|
+
) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
return `${resolved}${suffixAt === -1 ? "" : href.slice(suffixAt)}`;
|
|
280
|
+
};
|
|
281
|
+
|
|
252
282
|
/**
|
|
253
283
|
* Where a root-relative link to a content file (`/guide/new.md`, the way
|
|
254
284
|
* VitePress, Docsify, and TypeDoc write one) lands: the route of the source
|
|
@@ -695,6 +725,18 @@ export const routeOfLinkedFile = (
|
|
|
695
725
|
index.bySource.get(resolve(dirname(from.sourcePath), path)) ??
|
|
696
726
|
index.byDefaultNavPath.get(normalize(join(dirname(from.navPath), path)));
|
|
697
727
|
|
|
728
|
+
/**
|
|
729
|
+
* The route a file link written on a staged page lands on: the linked
|
|
730
|
+
* entry's, by its path under the staging dir (`entryId`), as the rendered
|
|
731
|
+
* link resolves it.
|
|
732
|
+
*/
|
|
733
|
+
export const routeOfLinkedEntry = (
|
|
734
|
+
index: FileRouteIndex,
|
|
735
|
+
entryId: string,
|
|
736
|
+
path: string
|
|
737
|
+
): string | undefined =>
|
|
738
|
+
index.byEntryId.get(normalize(join(dirname(entryId), path)));
|
|
739
|
+
|
|
698
740
|
/**
|
|
699
741
|
* The path a relative link on `page` resolves to — the same reading the
|
|
700
742
|
* Markdown pipeline rewrites the rendered `href` to (see
|
|
@@ -714,8 +756,7 @@ const relativeTarget = (
|
|
|
714
756
|
resolveFile = (path) =>
|
|
715
757
|
routeOfLinkedFile(ctx.fileRoutes, { navPath, sourcePath }, path);
|
|
716
758
|
} else if (entryId) {
|
|
717
|
-
resolveFile = (path) =>
|
|
718
|
-
ctx.fileRoutes.byEntryId.get(normalize(join(dirname(entryId), path)));
|
|
759
|
+
resolveFile = (path) => routeOfLinkedEntry(ctx.fileRoutes, entryId, path);
|
|
719
760
|
}
|
|
720
761
|
return (
|
|
721
762
|
resolveRelativeHref(rawPath, base, resolveFile, (route) =>
|
package/src/core/manifest.ts
CHANGED
|
@@ -136,6 +136,7 @@ const buildFallbackRoutes = (
|
|
|
136
136
|
locale: code,
|
|
137
137
|
monolingual: source.monolingual,
|
|
138
138
|
path,
|
|
139
|
+
published: source.published,
|
|
139
140
|
source: source.source,
|
|
140
141
|
sourcePath: source.sourcePath,
|
|
141
142
|
title: source.title,
|
|
@@ -201,6 +202,7 @@ export const buildManifest = (options: {
|
|
|
201
202
|
locale: page.locale,
|
|
202
203
|
monolingual: page.monolingual,
|
|
203
204
|
path: page.route,
|
|
205
|
+
published: page.published,
|
|
204
206
|
source: page.source,
|
|
205
207
|
sourcePath: page.sourcePath,
|
|
206
208
|
title: page.title,
|