blume 2.2.1 → 2.2.3
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-080mfch6.js → chunk-0wt5amrk.js} +15 -15
- package/dist/cli/{chunk-k45sq16q.js → chunk-0xyedh7k.js} +39 -23
- package/dist/cli/{chunk-k45sq16q.js.map → chunk-0xyedh7k.js.map} +3 -3
- package/dist/cli/{chunk-f260jdrn.js → chunk-0ynr0fr7.js} +5 -5
- package/dist/cli/{chunk-e1n41w9m.js → chunk-1ay3hzn0.js} +3 -3
- package/dist/cli/{chunk-b680sv30.js → chunk-2f6btenb.js} +10 -8
- package/dist/cli/chunk-2f6btenb.js.map +13 -0
- package/dist/cli/{chunk-gwy55cx3.js → chunk-426rytg6.js} +2 -2
- package/dist/cli/{chunk-9h8mh2tk.js → chunk-5f96yq2c.js} +1 -1
- package/dist/cli/{chunk-x60e15st.js → chunk-5xq12jz7.js} +17 -17
- package/dist/cli/{chunk-x7bh6d9j.js → chunk-6xag1c83.js} +4 -4
- package/dist/cli/{chunk-487b95qy.js → chunk-88q28zav.js} +41 -25
- package/dist/cli/{chunk-487b95qy.js.map → chunk-88q28zav.js.map} +3 -3
- package/dist/cli/{chunk-yg8ckc3g.js → chunk-88zkpg89.js} +2 -2
- package/dist/cli/{chunk-ppa6mg4d.js → chunk-8fm3b4vm.js} +6 -6
- package/dist/cli/{chunk-rtvgmg2j.js → chunk-8h2t2vyj.js} +2 -2
- package/dist/cli/{chunk-2ssh60qh.js → chunk-982qfeny.js} +6 -6
- package/dist/cli/{chunk-t2h8pwy1.js → chunk-9emfspcp.js} +4 -4
- package/dist/cli/{chunk-9jqmq5hv.js → chunk-akj2p7k1.js} +6 -6
- package/dist/cli/{chunk-beb0z983.js → chunk-b664x65a.js} +16 -16
- package/dist/cli/{chunk-kmswjxj9.js → chunk-b7n58hgj.js} +3 -3
- package/dist/cli/{chunk-fnxgtjeq.js → chunk-c0qddrxr.js} +1 -1
- package/dist/cli/{chunk-p834rkcn.js → chunk-g62vnvaw.js} +5 -5
- package/dist/cli/{chunk-zxd5jrgx.js → chunk-gc7knd2s.js} +76 -34
- package/dist/cli/{chunk-zxd5jrgx.js.map → chunk-gc7knd2s.js.map} +4 -4
- package/dist/cli/{chunk-r5nm9w0c.js → chunk-hwjspffr.js} +105 -36
- package/dist/cli/{chunk-r5nm9w0c.js.map → chunk-hwjspffr.js.map} +11 -10
- package/dist/cli/{chunk-40vk8kgf.js → chunk-p4fe7tax.js} +7 -7
- package/dist/cli/{chunk-34wap3ed.js → chunk-psg2za5z.js} +3 -3
- package/dist/cli/{chunk-hktq4j5r.js → chunk-pxp1y6yz.js} +16 -10
- package/dist/cli/{chunk-hktq4j5r.js.map → chunk-pxp1y6yz.js.map} +4 -4
- package/dist/cli/{chunk-qcyxkj44.js → chunk-qax1aekz.js} +43 -17
- package/dist/cli/chunk-qax1aekz.js.map +15 -0
- package/dist/cli/{chunk-h0qwdbem.js → chunk-r5ajrw1g.js} +2 -2
- package/dist/cli/{chunk-q1wwrxzw.js → chunk-rc2qk3qx.js} +3 -3
- package/dist/cli/chunk-rc2qk3qx.js.map +10 -0
- package/dist/cli/{chunk-69rjf786.js → chunk-ry2ymbxf.js} +5 -5
- package/dist/cli/{chunk-gxhcf2hs.js → chunk-t43g7v22.js} +2 -2
- package/dist/cli/{chunk-ydqzkhjn.js → chunk-x0jqyq1g.js} +6 -6
- package/dist/cli/{chunk-aafhwx74.js → chunk-x25s4gph.js} +1 -1
- package/dist/cli/{chunk-nqna8dzp.js → chunk-xkx216rd.js} +17 -17
- package/dist/cli/{chunk-etckvbrh.js → chunk-xv8nkt56.js} +14 -14
- package/dist/cli/{chunk-hn3g2b89.js → chunk-yrmw5n2b.js} +9 -9
- package/dist/cli/{chunk-4zxh25hc.js → chunk-zgca0vxs.js} +2 -2
- package/dist/cli/{chunk-c5bjxq8v.js → chunk-zj6bcx0n.js} +2 -2
- package/dist/cli/{chunk-1ch3ct3r.js → chunk-zjfwzrvd.js} +14 -14
- package/dist/cli/{chunk-j4nfgn2j.js → chunk-znwhf37a.js} +1 -1
- 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 +35 -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 +16 -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/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 +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/PageActions.astro +45 -18
- package/src/components/layout/PageLayout.astro +25 -12
- package/src/components/layout/RootLayout.astro +8 -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 +34 -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 +47 -9
- package/src/core/types.ts +10 -0
- package/src/deploy/sitemap.ts +127 -49
- package/src/openapi/proxy.ts +5 -1
- 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/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-080mfch6.js.map → chunk-0wt5amrk.js.map} +0 -0
- /package/dist/cli/{chunk-f260jdrn.js.map → chunk-0ynr0fr7.js.map} +0 -0
- /package/dist/cli/{chunk-e1n41w9m.js.map → chunk-1ay3hzn0.js.map} +0 -0
- /package/dist/cli/{chunk-gwy55cx3.js.map → chunk-426rytg6.js.map} +0 -0
- /package/dist/cli/{chunk-9h8mh2tk.js.map → chunk-5f96yq2c.js.map} +0 -0
- /package/dist/cli/{chunk-x60e15st.js.map → chunk-5xq12jz7.js.map} +0 -0
- /package/dist/cli/{chunk-x7bh6d9j.js.map → chunk-6xag1c83.js.map} +0 -0
- /package/dist/cli/{chunk-yg8ckc3g.js.map → chunk-88zkpg89.js.map} +0 -0
- /package/dist/cli/{chunk-ppa6mg4d.js.map → chunk-8fm3b4vm.js.map} +0 -0
- /package/dist/cli/{chunk-rtvgmg2j.js.map → chunk-8h2t2vyj.js.map} +0 -0
- /package/dist/cli/{chunk-2ssh60qh.js.map → chunk-982qfeny.js.map} +0 -0
- /package/dist/cli/{chunk-t2h8pwy1.js.map → chunk-9emfspcp.js.map} +0 -0
- /package/dist/cli/{chunk-9jqmq5hv.js.map → chunk-akj2p7k1.js.map} +0 -0
- /package/dist/cli/{chunk-beb0z983.js.map → chunk-b664x65a.js.map} +0 -0
- /package/dist/cli/{chunk-kmswjxj9.js.map → chunk-b7n58hgj.js.map} +0 -0
- /package/dist/cli/{chunk-fnxgtjeq.js.map → chunk-c0qddrxr.js.map} +0 -0
- /package/dist/cli/{chunk-p834rkcn.js.map → chunk-g62vnvaw.js.map} +0 -0
- /package/dist/cli/{chunk-40vk8kgf.js.map → chunk-p4fe7tax.js.map} +0 -0
- /package/dist/cli/{chunk-34wap3ed.js.map → chunk-psg2za5z.js.map} +0 -0
- /package/dist/cli/{chunk-h0qwdbem.js.map → chunk-r5ajrw1g.js.map} +0 -0
- /package/dist/cli/{chunk-69rjf786.js.map → chunk-ry2ymbxf.js.map} +0 -0
- /package/dist/cli/{chunk-gxhcf2hs.js.map → chunk-t43g7v22.js.map} +0 -0
- /package/dist/cli/{chunk-ydqzkhjn.js.map → chunk-x0jqyq1g.js.map} +0 -0
- /package/dist/cli/{chunk-aafhwx74.js.map → chunk-x25s4gph.js.map} +0 -0
- /package/dist/cli/{chunk-nqna8dzp.js.map → chunk-xkx216rd.js.map} +0 -0
- /package/dist/cli/{chunk-etckvbrh.js.map → chunk-xv8nkt56.js.map} +0 -0
- /package/dist/cli/{chunk-hn3g2b89.js.map → chunk-yrmw5n2b.js.map} +0 -0
- /package/dist/cli/{chunk-4zxh25hc.js.map → chunk-zgca0vxs.js.map} +0 -0
- /package/dist/cli/{chunk-c5bjxq8v.js.map → chunk-zj6bcx0n.js.map} +0 -0
- /package/dist/cli/{chunk-1ch3ct3r.js.map → chunk-zjfwzrvd.js.map} +0 -0
- /package/dist/cli/{chunk-j4nfgn2j.js.map → chunk-znwhf37a.js.map} +0 -0
package/src/ai/relative-links.ts
CHANGED
|
@@ -11,12 +11,15 @@ import { localePrefix } from "../core/i18n.ts";
|
|
|
11
11
|
import {
|
|
12
12
|
buildFileRouteIndex,
|
|
13
13
|
isIndexFileName,
|
|
14
|
+
resolveRawRelativeHref,
|
|
14
15
|
resolveRelativeHref,
|
|
15
16
|
resolveRootFileHref,
|
|
17
|
+
routeOfLinkedEntry,
|
|
16
18
|
routeOfLinkedFile,
|
|
17
19
|
} from "../core/links.ts";
|
|
18
|
-
import type { RelativeLinkBase } from "../core/links.ts";
|
|
20
|
+
import type { FileRouteIndex, RelativeLinkBase } from "../core/links.ts";
|
|
19
21
|
import { localizeHref, servesRoute } from "../core/locale-links.ts";
|
|
22
|
+
import type { LocalizeHrefOptions } from "../core/locale-links.ts";
|
|
20
23
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
21
24
|
import { resolveDocsCollection } from "../core/sources/collection.ts";
|
|
22
25
|
import { extractLinks } from "../core/sources/normalize.ts";
|
|
@@ -28,7 +31,9 @@ import { extractLinks } from "../core/sources/normalize.ts";
|
|
|
28
31
|
* link lands on `/install`; the rendered page rewrites it to the route it
|
|
29
32
|
* means (`markdown/relative-links.ts`), and the Markdown an agent reads gets
|
|
30
33
|
* the same rewrite from the same resolver (`resolveRelativeHref`), so both
|
|
31
|
-
* point at one page.
|
|
34
|
+
* point at one page. A raw HTML `<a href>` ships as written, so a relative
|
|
35
|
+
* one lands where the browser takes it from the page's URL
|
|
36
|
+
* (`resolveRawRelativeHref`). Root-relative links (`[x](/guide)`,
|
|
32
37
|
* `<Card href="/guide">`, ``) get what the rendered page
|
|
33
38
|
* gives them too (`markdown/base-links.ts`,
|
|
34
39
|
* `components/content/base-href.ts`): a page link the `deployment.base` +
|
|
@@ -40,10 +45,17 @@ import { extractLinks } from "../core/sources/normalize.ts";
|
|
|
40
45
|
* (`resolveRootFileHref`). Inline links, images, component
|
|
41
46
|
* `href`s, and reference definitions are rewritten; fenced and inline code,
|
|
42
47
|
* relative images and asset links, and external URLs are left as written.
|
|
48
|
+
* The assistant's snapshot takes the links that name a page by its path or
|
|
49
|
+
* file alone, as the routes it keys pages by (`assistant`).
|
|
43
50
|
*/
|
|
44
51
|
|
|
45
52
|
/** The page a Markdown text was written for. */
|
|
46
53
|
export interface LinkedPage {
|
|
54
|
+
/**
|
|
55
|
+
* A staged page's path under its staging dir, which its links resolve from
|
|
56
|
+
* when it has no `sourcePath` (a remote or CMS source).
|
|
57
|
+
*/
|
|
58
|
+
entryId?: string;
|
|
47
59
|
route: string;
|
|
48
60
|
sourcePath?: string;
|
|
49
61
|
}
|
|
@@ -51,6 +63,20 @@ export interface LinkedPage {
|
|
|
51
63
|
/** Rewrite a page's relative page links to the routes they mean. */
|
|
52
64
|
export type RelativeLinkRewriter = (text: string, page: LinkedPage) => string;
|
|
53
65
|
|
|
66
|
+
/** Who reads the rewritten links. */
|
|
67
|
+
export interface RelativeLinkOptions {
|
|
68
|
+
/**
|
|
69
|
+
* The assistant's snapshot, rather than a page an agent fetches by URL.
|
|
70
|
+
* Only a link that names a page by its relative path or its content file
|
|
71
|
+
* changes, to the route the snapshot keys that page by: without the
|
|
72
|
+
* `deployment.base`, since the chat panel mounts every link it renders
|
|
73
|
+
* under it, and a locale's fallback copy as the page it renders, since the
|
|
74
|
+
* snapshot holds that page instead. Root page links and images stay as
|
|
75
|
+
* written.
|
|
76
|
+
*/
|
|
77
|
+
assistant?: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
/**
|
|
55
81
|
* Whether a text could hold a relative page link at all: an inline link, a
|
|
56
82
|
* reference definition, or a component `href` whose target isn't a URL with
|
|
@@ -123,16 +149,79 @@ const collectSplices = (
|
|
|
123
149
|
return splices;
|
|
124
150
|
};
|
|
125
151
|
|
|
152
|
+
/** What a project's links resolve through, whoever reads them. */
|
|
153
|
+
interface LinkIndex {
|
|
154
|
+
fileRoutes: FileRouteIndex;
|
|
155
|
+
localeByRoute: Map<string, string>;
|
|
156
|
+
navPathBySource: Map<string | undefined, string>;
|
|
157
|
+
routes: Set<string>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The mirrors, llms-full.txt and the assistant's snapshot each build a
|
|
161
|
+
// rewriter for the same project, so the lookups behind them are built once.
|
|
162
|
+
const linkIndexes = new WeakMap<BlumeProject, LinkIndex>();
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The file → route lookup a `.md`/`.mdx` link resolves through, and the
|
|
166
|
+
* routes a link can land on. A file shared by every locale publishes once
|
|
167
|
+
* per locale, and the default locale's route stands for it; fallback copies
|
|
168
|
+
* render another locale's file, so they never stand for one.
|
|
169
|
+
*/
|
|
170
|
+
const linkIndex = (project: BlumeProject): LinkIndex => {
|
|
171
|
+
let index = linkIndexes.get(project);
|
|
172
|
+
if (!index) {
|
|
173
|
+
const { config, graph, manifest } = project;
|
|
174
|
+
index = {
|
|
175
|
+
fileRoutes: buildFileRouteIndex(graph.pages, config.i18n ?? null),
|
|
176
|
+
localeByRoute: new Map(
|
|
177
|
+
manifest.routes.map((route) => [route.path, route.locale])
|
|
178
|
+
),
|
|
179
|
+
navPathBySource: new Map(
|
|
180
|
+
graph.pages.map((page) => [page.sourcePath, page.navPath])
|
|
181
|
+
),
|
|
182
|
+
routes: new Set(manifest.routes.map((route) => route.path)),
|
|
183
|
+
};
|
|
184
|
+
linkIndexes.set(project, index);
|
|
185
|
+
}
|
|
186
|
+
return index;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A resolved route as the assistant's snapshot keys it. A locale's fallback
|
|
191
|
+
* copy (`/fr/guides/install` with no French page there) renders another
|
|
192
|
+
* locale's page, which the snapshot holds in its place, so the route becomes
|
|
193
|
+
* that page's; every other route is kept, `?query#hash` and all.
|
|
194
|
+
*/
|
|
195
|
+
const snapshotRoute = (project: BlumeProject): ((route: string) => string) => {
|
|
196
|
+
const pageRoutes = new Map(
|
|
197
|
+
project.graph.pages.map((page) => [page.id, page.route])
|
|
198
|
+
);
|
|
199
|
+
const rendered = new Map<string, string>();
|
|
200
|
+
for (const route of project.manifest.routes) {
|
|
201
|
+
const page = route.fallback ? pageRoutes.get(route.id) : undefined;
|
|
202
|
+
if (page !== undefined) {
|
|
203
|
+
rendered.set(route.path, page);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return (route) => {
|
|
207
|
+
const suffixAt = route.search(/[?#]/u);
|
|
208
|
+
const path = suffixAt === -1 ? route : route.slice(0, suffixAt);
|
|
209
|
+
const page = rendered.get(path);
|
|
210
|
+
return page === undefined ? route : `${page}${route.slice(path.length)}`;
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
|
|
126
214
|
/**
|
|
127
|
-
* Build the rewriter for a project
|
|
128
|
-
*
|
|
129
|
-
* publishes once per locale, and the default locale's route stands for it;
|
|
130
|
-
* fallback copies render another locale's file, so they never stand for one.
|
|
215
|
+
* Build the rewriter for a project, for an agent reading a page by URL or,
|
|
216
|
+
* with `assistant`, for the assistant's snapshot.
|
|
131
217
|
*/
|
|
132
218
|
export const relativeLinkRewriter = (
|
|
133
|
-
project: BlumeProject
|
|
219
|
+
project: BlumeProject,
|
|
220
|
+
options: RelativeLinkOptions = {}
|
|
134
221
|
): RelativeLinkRewriter => {
|
|
135
222
|
const { basePath, deployment, i18n } = project.config;
|
|
223
|
+
// Links are read under the real deployment base whoever reads them, so a
|
|
224
|
+
// root link to a content file that carries it is still recognized.
|
|
136
225
|
const deployBase = normalizeBasePath(deployment.options.base);
|
|
137
226
|
const contentRoot = resolveDocsCollection(
|
|
138
227
|
project.config,
|
|
@@ -141,17 +230,38 @@ export const relativeLinkRewriter = (
|
|
|
141
230
|
const localeTokens = i18n
|
|
142
231
|
? ["$", ...i18n.locales.map((locale) => locale.code)]
|
|
143
232
|
: [];
|
|
144
|
-
const fileRoutes
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
233
|
+
const { fileRoutes, localeByRoute, navPathBySource, routes } =
|
|
234
|
+
linkIndex(project);
|
|
235
|
+
const hasRoute = (path: string): boolean => routes.has(path);
|
|
236
|
+
|
|
237
|
+
// Where a resolved route lands for its reader. A route Blume serves takes
|
|
238
|
+
// the deployment base unconditionally — even over a route that starts with
|
|
239
|
+
// the base's name — except in the assistant's snapshot.
|
|
240
|
+
const land = options.assistant
|
|
241
|
+
? snapshotRoute(project)
|
|
242
|
+
: (route: string): string => mountBasePath(deployBase, route);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* How a page's root-relative links change, if they do: when the site is
|
|
246
|
+
* mounted under a prefix, or the page reads in a prefixed locale, whose
|
|
247
|
+
* copy of a route they move to. The assistant's snapshot keeps them as
|
|
248
|
+
* written.
|
|
249
|
+
*/
|
|
250
|
+
const rootLinksOf = (
|
|
251
|
+
route: string
|
|
252
|
+
): { localize?: LocalizeHrefOptions } | undefined => {
|
|
253
|
+
if (options.assistant) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
const locale = localeByRoute.get(route);
|
|
257
|
+
const localize =
|
|
258
|
+
i18n && locale !== undefined && localePrefix(locale, i18n) !== ""
|
|
259
|
+
? { basePath, deployBase, i18n, locale, routes }
|
|
260
|
+
: undefined;
|
|
261
|
+
return deployBase !== "" || basePath !== "" || localize !== undefined
|
|
262
|
+
? { localize }
|
|
263
|
+
: undefined;
|
|
264
|
+
};
|
|
155
265
|
|
|
156
266
|
/**
|
|
157
267
|
* A page's relative-link resolver; `undefined` when the page has no file
|
|
@@ -160,43 +270,36 @@ export const relativeLinkRewriter = (
|
|
|
160
270
|
const relativeRoute = (
|
|
161
271
|
page: LinkedPage
|
|
162
272
|
): ((target: string) => string | undefined) | undefined => {
|
|
163
|
-
const { sourcePath } = page;
|
|
164
|
-
|
|
273
|
+
const { entryId, sourcePath } = page;
|
|
274
|
+
const file = sourcePath ?? entryId;
|
|
275
|
+
if (!file) {
|
|
165
276
|
return;
|
|
166
277
|
}
|
|
167
278
|
const from: RelativeLinkBase = {
|
|
168
|
-
isIndex: isIndexFileName(basename(
|
|
279
|
+
isIndex: isIndexFileName(basename(file), localeTokens),
|
|
169
280
|
route: page.route,
|
|
170
281
|
};
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
|
|
282
|
+
// A staged page (a remote or CMS source) has no file on disk: it
|
|
283
|
+
// resolves a sibling by its entry id, as `blume validate` does.
|
|
284
|
+
let resolveFile: (path: string) => string | undefined;
|
|
285
|
+
if (sourcePath) {
|
|
286
|
+
const navPath = navPathBySource.get(sourcePath) ?? basename(sourcePath);
|
|
287
|
+
resolveFile = (path) =>
|
|
288
|
+
routeOfLinkedFile(fileRoutes, { navPath, sourcePath }, path);
|
|
289
|
+
} else {
|
|
290
|
+
resolveFile = (path) => routeOfLinkedEntry(fileRoutes, file, path);
|
|
291
|
+
}
|
|
292
|
+
return (target) => resolveRelativeHref(target, from, resolveFile, hasRoute);
|
|
182
293
|
};
|
|
183
294
|
|
|
184
295
|
/** Whether a page is served at a based, fragment-less path. */
|
|
185
296
|
const servesPage = (route: string): boolean => servesRoute(routes, route);
|
|
186
297
|
|
|
187
298
|
return (text, page) => {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
const localize =
|
|
191
|
-
i18n && locale !== undefined && localePrefix(locale, i18n) !== ""
|
|
192
|
-
? { basePath, deployBase, i18n, locale, routes }
|
|
193
|
-
: undefined;
|
|
194
|
-
// A root-relative link only changes when the site is mounted under a
|
|
195
|
-
// prefix or the page reads in a prefixed locale.
|
|
196
|
-
const rootLinks =
|
|
197
|
-
deployBase !== "" || basePath !== "" || localize !== undefined;
|
|
299
|
+
const rootLinks = rootLinksOf(page.route);
|
|
300
|
+
const localize = rootLinks?.localize;
|
|
198
301
|
if (
|
|
199
|
-
!(page.sourcePath || rootLinks) ||
|
|
302
|
+
!(page.sourcePath || page.entryId || rootLinks) ||
|
|
200
303
|
!(
|
|
201
304
|
(rootLinks ? MAYBE_PAGE_LINK : MAYBE_RELATIVE).test(text) ||
|
|
202
305
|
MAYBE_ROOT_FILE.test(text)
|
|
@@ -230,20 +333,24 @@ export const relativeLinkRewriter = (
|
|
|
230
333
|
if (route === undefined) {
|
|
231
334
|
return undefined;
|
|
232
335
|
}
|
|
233
|
-
const
|
|
234
|
-
return localize ? localizeHref(
|
|
336
|
+
const landed = land(route);
|
|
337
|
+
return localize ? localizeHref(landed, localize) : landed;
|
|
235
338
|
};
|
|
236
339
|
const routed = (target: string, raw: boolean): string | undefined => {
|
|
237
340
|
if (!isInternalPath(target)) {
|
|
238
|
-
|
|
341
|
+
const route = raw
|
|
342
|
+
? resolveRawRelativeHref(target, page.route, hasRoute)
|
|
343
|
+
: relative?.(target);
|
|
344
|
+
return route === undefined ? undefined : land(route);
|
|
239
345
|
}
|
|
240
|
-
|
|
346
|
+
const file = raw ? undefined : rootFile(target);
|
|
347
|
+
return file ?? (rootLinks ? rooted(target) : undefined);
|
|
241
348
|
};
|
|
242
349
|
// An image is always a file: `public/` (or a generated asset endpoint),
|
|
243
350
|
// served under the deployment base but never `basePath`. A relative one
|
|
244
351
|
// is left to `core/content-assets.ts`.
|
|
245
352
|
const imaged = (target: string): string | undefined => {
|
|
246
|
-
const based = withBasePath(deployBase, target);
|
|
353
|
+
const based = rootLinks ? withBasePath(deployBase, target) : target;
|
|
247
354
|
return based === target ? undefined : based;
|
|
248
355
|
};
|
|
249
356
|
|
package/src/ai/site-skill.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { NavNode, PageRecord } from "../core/types.ts";
|
|
|
14
14
|
import { resolveReferences } from "../openapi/references.ts";
|
|
15
15
|
import { docsPhrase } from "./ai-catalog.ts";
|
|
16
16
|
import { API_PAGES_PATH } from "./api/paths.ts";
|
|
17
|
+
import { linkText } from "./link-text.ts";
|
|
17
18
|
import { asciiSlugify } from "./mcp/discovery.ts";
|
|
18
19
|
import {
|
|
19
20
|
SKILL_DESCRIPTION_MAX,
|
|
@@ -54,10 +55,6 @@ export const siteSkillName = (project: BlumeProject): string => {
|
|
|
54
55
|
return name.slice(0, NAME_MAX).replace(/-+$/u, "");
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
/** A title as Markdown link text (brackets escaped, as in llms.txt). */
|
|
58
|
-
const linkText = (title: string): string =>
|
|
59
|
-
title.replaceAll(/[\\[\]]/gu, String.raw`\$&`);
|
|
60
|
-
|
|
61
58
|
/**
|
|
62
59
|
* Current-version, default-locale pages worth listing: not API operations,
|
|
63
60
|
* and not changelog entries, which the changelog index covers in one line.
|
package/src/astro/generate.ts
CHANGED
|
@@ -88,6 +88,7 @@ import type { Diagnostic, Navigation } from "../core/types.ts";
|
|
|
88
88
|
import { getBlumeVersion } from "../core/version.ts";
|
|
89
89
|
import { servedFiles } from "../deploy/generated-files.ts";
|
|
90
90
|
import { buildRssFeeds, renderRssFeed } from "../deploy/rss.ts";
|
|
91
|
+
import { hreflangAlternates } from "../deploy/sitemap.ts";
|
|
91
92
|
import {
|
|
92
93
|
languageIconCss,
|
|
93
94
|
languageIconSlugsIn,
|
|
@@ -1204,6 +1205,8 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1204
1205
|
i18n?.defaultLocale
|
|
1205
1206
|
);
|
|
1206
1207
|
|
|
1208
|
+
const hreflang = hreflangAlternates(project);
|
|
1209
|
+
|
|
1207
1210
|
const navigationByLocale = i18n
|
|
1208
1211
|
? Object.fromEntries(
|
|
1209
1212
|
i18n.locales.map(({ code }) => [
|
|
@@ -1273,6 +1276,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1273
1276
|
}
|
|
1274
1277
|
: null,
|
|
1275
1278
|
imageZoom: config.markdown.imageZoom,
|
|
1279
|
+
jsonLd: config.seo.jsonLd,
|
|
1276
1280
|
logo,
|
|
1277
1281
|
mcp: config.agents.mcp.enabled
|
|
1278
1282
|
? {
|
|
@@ -1293,6 +1297,7 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1293
1297
|
og: {
|
|
1294
1298
|
description: resolveOgDescription(config),
|
|
1295
1299
|
enabled: config.seo.og.enabled ?? false,
|
|
1300
|
+
image: config.seo.og.image,
|
|
1296
1301
|
logo: ogLogo,
|
|
1297
1302
|
palette: config.seo.og.palette,
|
|
1298
1303
|
site: resolveOgSite(config),
|
|
@@ -1353,12 +1358,14 @@ export const buildRuntimeData = (project: BlumeProject): string => {
|
|
|
1353
1358
|
entryId: route.entryId,
|
|
1354
1359
|
fallback: route.fallback ?? false,
|
|
1355
1360
|
hidden: route.hidden,
|
|
1361
|
+
hreflang: hreflang.get(route.path) ?? [],
|
|
1356
1362
|
id: route.id,
|
|
1357
1363
|
indexable: route.indexable,
|
|
1358
1364
|
lastModified: route.lastModified ?? null,
|
|
1359
1365
|
locale: route.locale,
|
|
1360
1366
|
monolingual: route.monolingual ?? false,
|
|
1361
1367
|
path: route.path,
|
|
1368
|
+
published: route.published ?? null,
|
|
1362
1369
|
title: route.title,
|
|
1363
1370
|
version: route.version,
|
|
1364
1371
|
versionAlternates: route.versionAlternates,
|
package/src/astro/templates.ts
CHANGED
|
@@ -2512,6 +2512,7 @@ import RootLayout from "blume/components/layout/RootLayout.astro";
|
|
|
2512
2512
|
import { withBase, withMountedBase } from "blume/components/islands/base-path.ts";
|
|
2513
2513
|
import { mountBasePath, stripBasePath } from "blume/core/base-path.ts";
|
|
2514
2514
|
import { routeSetFor, servesRoute } from "blume/core/locale-links.ts";
|
|
2515
|
+
import { resolveLocalizable } from "blume/core/localizable.ts";
|
|
2515
2516
|
import { resolveSlot } from "blume/components/layout/overrides.ts";
|
|
2516
2517
|
${componentImports}
|
|
2517
2518
|
import data from "blume:data";
|
|
@@ -2536,8 +2537,10 @@ export function getStaticPaths() {
|
|
|
2536
2537
|
editUrl: route.editUrl,
|
|
2537
2538
|
entryId: route.entryId,
|
|
2538
2539
|
fallback: route.fallback,
|
|
2540
|
+
hreflang: route.hreflang,
|
|
2539
2541
|
indexable: route.indexable,
|
|
2540
2542
|
lastModified: route.lastModified,
|
|
2543
|
+
published: route.published,
|
|
2541
2544
|
locale: route.locale,
|
|
2542
2545
|
monolingual: route.monolingual,
|
|
2543
2546
|
route: route.path,
|
|
@@ -2548,7 +2551,7 @@ export function getStaticPaths() {
|
|
|
2548
2551
|
}));
|
|
2549
2552
|
}
|
|
2550
2553
|
|
|
2551
|
-
const { entryId, collection, route, title, indexable, editUrl, lastModified, locale, alternates, fallback, monolingual, version, versionAlternates } = Astro.props;
|
|
2554
|
+
const { entryId, collection, route, title, indexable, editUrl, lastModified, published, locale, alternates, hreflang, fallback, monolingual, version, versionAlternates } = Astro.props;
|
|
2552
2555
|
const entry = await getEntry(collection as CollectionKey, entryId);
|
|
2553
2556
|
if (!entry) {
|
|
2554
2557
|
return new Response(null, { status: 404 });
|
|
@@ -2598,19 +2601,23 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
|
|
|
2598
2601
|
const ogPath = data.config.og.enabled
|
|
2599
2602
|
? encodeURI(\`/og/\${route === "/" ? "index" : route.slice(1)}.png\`)
|
|
2600
2603
|
: null;
|
|
2601
|
-
//
|
|
2602
|
-
// \`site + base + path\`.
|
|
2604
|
+
// Root-relative paths carry the deployment base (the page is served under it),
|
|
2605
|
+
// and the site too once it's known: \`site + base + path\`. \`seo.image\` may be
|
|
2603
2606
|
// an external URL, which passes through verbatim (mirrors PageLayout). An
|
|
2604
|
-
// authored
|
|
2605
|
-
//
|
|
2607
|
+
// authored image keeps a base written by hand; the generated card is always
|
|
2608
|
+
// mounted under it.
|
|
2606
2609
|
const absoluteOg = (path: string, based: (path: string) => string) =>
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
+
path.startsWith("/") ? \`\${base ?? ""}\${based(path)}\` : path;
|
|
2611
|
+
// The page's own image, else the site's default for its locale.
|
|
2612
|
+
const selectedOgImage =
|
|
2613
|
+
seo.image ??
|
|
2614
|
+
(data.config.og.image && resolveLocalizable(data.config.og.image, locale, data.config.i18n?.defaultLocale));
|
|
2615
|
+
const ogImage = selectedOgImage
|
|
2616
|
+
? absoluteOg(selectedOgImage, withBase)
|
|
2610
2617
|
: ogPath && absoluteOg(ogPath, withMountedBase);
|
|
2611
|
-
// Blume's generated card has known dimensions the layout can declare;
|
|
2612
|
-
//
|
|
2613
|
-
const ogGenerated = !
|
|
2618
|
+
// Blume's generated card has known dimensions the layout can declare; an
|
|
2619
|
+
// authored image could be any size or format, so it gets none.
|
|
2620
|
+
const ogGenerated = !selectedOgImage && Boolean(ogPath);
|
|
2614
2621
|
|
|
2615
2622
|
// X attribution: the site's account, plus a creator the page can claim for
|
|
2616
2623
|
// itself (a guest post crediting its own author) over the configured default.
|
|
@@ -2706,11 +2713,12 @@ const canonical =
|
|
|
2706
2713
|
: null);
|
|
2707
2714
|
const effectiveNoindex = Boolean(seo.noindex) || (archived?.noindex ?? false);
|
|
2708
2715
|
|
|
2716
|
+
// hreflang names only indexable translations (see \`hreflangAlternates\`).
|
|
2709
2717
|
const localeAlternates =
|
|
2710
2718
|
i18n && base
|
|
2711
|
-
? (
|
|
2719
|
+
? (hreflang ?? []).map((alt) => ({ hreflang: alt.locale, href: absolute(alt.path) }))
|
|
2712
2720
|
: [];
|
|
2713
|
-
const defaultAlt = i18n ? (
|
|
2721
|
+
const defaultAlt = i18n ? (hreflang ?? []).find((alt) => alt.locale === i18n.defaultLocale) : null;
|
|
2714
2722
|
const xDefault = defaultAlt && base ? absolute(defaultAlt.path) : null;
|
|
2715
2723
|
|
|
2716
2724
|
// \`route\` arrives with \`basePath\` already applied, so the locale segment it
|
|
@@ -2874,7 +2882,8 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
2874
2882
|
pageType={frontmatter.type}
|
|
2875
2883
|
apiRail={remarkPluginFrontmatter?.${API_RAIL_KEY} === true}
|
|
2876
2884
|
pageMode={frontmatter.mode}
|
|
2877
|
-
published={frontmatter.date ?? frontmatter.changelog?.date ??
|
|
2885
|
+
published={frontmatter.date ?? frontmatter.changelog?.date ?? published}
|
|
2886
|
+
authors={frontmatter.authors}
|
|
2878
2887
|
lastModified={lastModified}
|
|
2879
2888
|
noindex={effectiveNoindex}
|
|
2880
2889
|
structuredDataEnabled={data.config.structuredData}
|
|
@@ -2922,8 +2931,9 @@ export const changelogIndexTemplate = (options: {
|
|
|
2922
2931
|
// Generated by Blume. Do not edit.
|
|
2923
2932
|
import RootLayout from "blume/components/layout/RootLayout.astro";
|
|
2924
2933
|
import Changelog from "blume/components/content/Changelog.astro";
|
|
2925
|
-
import { withMountedBase } from "blume/components/islands/base-path.ts";
|
|
2934
|
+
import { withBase, withMountedBase } from "blume/components/islands/base-path.ts";
|
|
2926
2935
|
import { resolveSlot } from "blume/components/layout/overrides.ts";
|
|
2936
|
+
import { resolveLocalizable } from "blume/core/localizable.ts";
|
|
2927
2937
|
import { layoutOverrides } from "../generated/components.ts";
|
|
2928
2938
|
import data from "blume:data";
|
|
2929
2939
|
|
|
@@ -2945,11 +2955,17 @@ const base = data.config.site ? data.config.site.replace(/\\/$/, "") : null;
|
|
|
2945
2955
|
const basedRoute = withMountedBase("/changelog");
|
|
2946
2956
|
const canonical = base ? base + basedRoute : null;
|
|
2947
2957
|
|
|
2948
|
-
// The generated OG card for this route (the /og
|
|
2949
|
-
// the content-route cards), absolutized like the
|
|
2950
|
-
// a full URL when the site is known.
|
|
2958
|
+
// The site's default image, else the generated OG card for this route (the /og
|
|
2959
|
+
// endpoint emits it alongside the content-route cards), absolutized like the
|
|
2960
|
+
// catch-all's so crawlers get a full URL when the site is known.
|
|
2961
|
+
const defaultOgImage =
|
|
2962
|
+
data.config.og.image && resolveLocalizable(data.config.og.image, htmlLang, i18n?.defaultLocale);
|
|
2951
2963
|
const ogPath = data.config.og.enabled ? withMountedBase("/og/changelog.png") : null;
|
|
2952
|
-
const ogImage =
|
|
2964
|
+
const ogImage = defaultOgImage
|
|
2965
|
+
? defaultOgImage.startsWith("/")
|
|
2966
|
+
? (base ?? "") + withBase(defaultOgImage)
|
|
2967
|
+
: defaultOgImage
|
|
2968
|
+
: ogPath && base ? base + ogPath : ogPath;
|
|
2953
2969
|
|
|
2954
2970
|
// The page chrome (h1, title, description) comes from the translatable
|
|
2955
2971
|
// \`changelog\` group, which carries the \`changelog\` config's title and
|
|
@@ -2997,7 +3013,7 @@ const LayoutComponent = resolveSlot(layoutOverrides.Layout, RootLayout);
|
|
|
2997
3013
|
searchEnabled={data.config.search.enabled}
|
|
2998
3014
|
indexable={true}
|
|
2999
3015
|
ogImage={ogImage}
|
|
3000
|
-
ogGenerated={Boolean(ogImage)}
|
|
3016
|
+
ogGenerated={!defaultOgImage && Boolean(ogImage)}
|
|
3001
3017
|
x={data.config.x}
|
|
3002
3018
|
canonical={canonical}
|
|
3003
3019
|
exportPdf={${options.exportPdf}}
|
|
@@ -115,11 +115,17 @@ const rowClass =
|
|
|
115
115
|
"flex w-full items-center gap-2.5 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:text-foreground";
|
|
116
116
|
|
|
117
117
|
// Items inside the floating popovers keep menu affordances (padding + hover
|
|
118
|
-
// background).
|
|
118
|
+
// background). Their icons sit in a box one line tall, so a label that wraps
|
|
119
|
+
// keeps them beside its first line.
|
|
119
120
|
const menuRowClass =
|
|
120
|
-
"flex w-full items-
|
|
121
|
+
"flex w-full items-start gap-2.5 rounded-md px-2 py-1.5 text-start text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground";
|
|
122
|
+
const menuIconClass = "flex h-lh shrink-0 items-center";
|
|
123
|
+
// At most out to the rail's edge, past its padding (`--blume-rail-inset`, set
|
|
124
|
+
// by the layout), less 2px so the border never lands on the edge: the rail
|
|
125
|
+
// scrolls, so it clips anything wider. Longer labels wrap instead. The minimum
|
|
126
|
+
// width gives way to that cap in a narrower rail.
|
|
121
127
|
const panelClass =
|
|
122
|
-
"absolute top-full
|
|
128
|
+
"absolute top-full end-0 z-50 mt-1 w-max [--blume-menu-room:calc(100%+var(--blume-rail-inset,1rem)-2px)] min-w-[min(14rem,var(--blume-menu-room))] max-w-(--blume-menu-room) wrap-anywhere rounded-blume border border-border bg-background p-1 shadow-xl";
|
|
123
129
|
---
|
|
124
130
|
|
|
125
131
|
<div
|
|
@@ -172,13 +178,17 @@ const panelClass =
|
|
|
172
178
|
<div class={panelClass} data-blume-menu>
|
|
173
179
|
{exportPdf && (
|
|
174
180
|
<button class={menuRowClass} data-blume-export-pdf type="button">
|
|
175
|
-
<
|
|
181
|
+
<span class={menuIconClass}>
|
|
182
|
+
<Icon name="file" size={16} />
|
|
183
|
+
</span>
|
|
176
184
|
<span class="flex-1">{a.exportPdf}</span>
|
|
177
185
|
</button>
|
|
178
186
|
)}
|
|
179
187
|
{exportEpub && (
|
|
180
188
|
<button class={menuRowClass} data-blume-export-epub type="button">
|
|
181
|
-
<
|
|
189
|
+
<span class={menuIconClass}>
|
|
190
|
+
<Icon name="book-open" size={16} />
|
|
191
|
+
</span>
|
|
182
192
|
<span class="flex-1" data-blume-epub-label>
|
|
183
193
|
{a.exportEpub}
|
|
184
194
|
</span>
|
|
@@ -214,9 +224,11 @@ const panelClass =
|
|
|
214
224
|
data-open-in={provider.key}
|
|
215
225
|
{...NEW_TAB_ATTRS}
|
|
216
226
|
>
|
|
217
|
-
<span set:html={LOGOS[provider.key]} />
|
|
227
|
+
<span class={menuIconClass} set:html={LOGOS[provider.key]} />
|
|
218
228
|
<span class="flex-1">{provider.label}</span>
|
|
219
|
-
<
|
|
229
|
+
<span class={menuIconClass}>
|
|
230
|
+
<Icon name="external-link" size={13} />
|
|
231
|
+
</span>
|
|
220
232
|
</a>
|
|
221
233
|
</>
|
|
222
234
|
))}
|
|
@@ -241,7 +253,9 @@ const panelClass =
|
|
|
241
253
|
</summary>
|
|
242
254
|
<div class={panelClass} data-blume-menu>
|
|
243
255
|
<button class={menuRowClass} data-mcp-copy-url type="button">
|
|
244
|
-
<
|
|
256
|
+
<span class={menuIconClass}>
|
|
257
|
+
<Icon name="link" size={16} />
|
|
258
|
+
</span>
|
|
245
259
|
<span class="flex-1" data-mcp-url-label>
|
|
246
260
|
{a.copyServerUrl}
|
|
247
261
|
</span>
|
|
@@ -255,7 +269,7 @@ const panelClass =
|
|
|
255
269
|
data-mcp-copy-claude
|
|
256
270
|
type="button"
|
|
257
271
|
>
|
|
258
|
-
<span set:html={LOGOS.claude} />
|
|
272
|
+
<span class={menuIconClass} set:html={LOGOS.claude} />
|
|
259
273
|
<span class="flex-1" data-mcp-claude-label>
|
|
260
274
|
{a.copyClaudeCode}
|
|
261
275
|
</span>
|
|
@@ -263,7 +277,7 @@ const panelClass =
|
|
|
263
277
|
)}
|
|
264
278
|
{client === "codex" && (
|
|
265
279
|
<button class={menuRowClass} data-mcp-copy-codex type="button">
|
|
266
|
-
<span set:html={LOGOS.codex} />
|
|
280
|
+
<span class={menuIconClass} set:html={LOGOS.codex} />
|
|
267
281
|
<span class="flex-1" data-mcp-codex-label>
|
|
268
282
|
{a.copyCodex}
|
|
269
283
|
</span>
|
|
@@ -271,16 +285,22 @@ const panelClass =
|
|
|
271
285
|
)}
|
|
272
286
|
{client === "cursor" && (
|
|
273
287
|
<a class={menuRowClass} data-mcp-cursor rel="noreferrer">
|
|
274
|
-
<span set:html={LOGOS.cursor} />
|
|
288
|
+
<span class={menuIconClass} set:html={LOGOS.cursor} />
|
|
275
289
|
<span class="flex-1">{a.addToCursor}</span>
|
|
276
|
-
<
|
|
290
|
+
<span class={menuIconClass}>
|
|
291
|
+
<Icon name="external-link" size={13} />
|
|
292
|
+
</span>
|
|
277
293
|
</a>
|
|
278
294
|
)}
|
|
279
295
|
{client === "vscode" && (
|
|
280
296
|
<a class={menuRowClass} data-mcp-vscode rel="noreferrer">
|
|
281
|
-
<
|
|
297
|
+
<span class={menuIconClass}>
|
|
298
|
+
<Icon name="code" size={16} />
|
|
299
|
+
</span>
|
|
282
300
|
<span class="flex-1">{a.addToVscode}</span>
|
|
283
|
-
<
|
|
301
|
+
<span class={menuIconClass}>
|
|
302
|
+
<Icon name="external-link" size={13} />
|
|
303
|
+
</span>
|
|
284
304
|
</a>
|
|
285
305
|
)}
|
|
286
306
|
{!isBuiltInMcpClient(client) && (
|
|
@@ -289,7 +309,9 @@ const panelClass =
|
|
|
289
309
|
data-mcp-copy-command={client.command}
|
|
290
310
|
type="button"
|
|
291
311
|
>
|
|
292
|
-
<
|
|
312
|
+
<span class={menuIconClass}>
|
|
313
|
+
<Icon name={client.icon ?? "terminal"} size={16} />
|
|
314
|
+
</span>
|
|
293
315
|
<span class="flex-1" data-mcp-command-label>
|
|
294
316
|
{client.label}
|
|
295
317
|
</span>
|
|
@@ -442,10 +464,15 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
442
464
|
return;
|
|
443
465
|
}
|
|
444
466
|
const rect = summary.getBoundingClientRect();
|
|
467
|
+
// The rail scrolls, so it clips the menu at its own edges: its top sits
|
|
468
|
+
// under the sticky header, below the viewport's.
|
|
469
|
+
const room = details
|
|
470
|
+
.closest("[data-blume-toc]")
|
|
471
|
+
?.getBoundingClientRect() ?? { bottom: window.innerHeight, top: 0 };
|
|
445
472
|
const margin = 8;
|
|
446
473
|
const flipUp =
|
|
447
|
-
rect.bottom + menu.offsetHeight + margin >
|
|
448
|
-
rect.top - menu.offsetHeight - margin >
|
|
474
|
+
rect.bottom + menu.offsetHeight + margin > room.bottom &&
|
|
475
|
+
rect.top - menu.offsetHeight - margin > room.top;
|
|
449
476
|
menu.classList.toggle("top-full", !flipUp);
|
|
450
477
|
menu.classList.toggle("mt-1", !flipUp);
|
|
451
478
|
menu.classList.toggle("bottom-full", flipUp);
|
|
@@ -513,7 +540,7 @@ hr { border: 0; border-top: 1px solid #ddd; margin: 2em 0; }`;
|
|
|
513
540
|
}
|
|
514
541
|
// The action dropdowns are native <details>. Make them behave like a proper
|
|
515
542
|
// menu: only one open at a time, and flip above the trigger when opening
|
|
516
|
-
// downward would run past the
|
|
543
|
+
// downward would run past the rail's bottom and there's room above.
|
|
517
544
|
const dropdowns = [...root.querySelectorAll<HTMLDetailsElement>("details")];
|
|
518
545
|
for (const details of dropdowns) {
|
|
519
546
|
details.addEventListener("toggle", () => {
|