blume 0.6.7 → 0.8.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 +618 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +2609 -1041
- package/dist/cli/index.js.map +110 -103
- package/dist/types/ai/component-markdown.d.ts +34 -0
- package/dist/types/components/content/youtube.d.ts +18 -0
- package/dist/types/core/base-path.d.ts +47 -0
- package/dist/types/core/config-input.d.ts +110 -12
- package/dist/types/core/config.d.ts +6 -4
- package/dist/types/core/data.d.ts +4 -0
- package/dist/types/core/i18n-ui.d.ts +477 -135
- package/dist/types/core/schema.d.ts +309 -195
- package/dist/types/core/sources/types.d.ts +2 -0
- package/dist/types/core/types.d.ts +6 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +24 -9
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/custom-pages.mdx +1 -1
- package/docs/advanced/skills.mdx +1 -1
- package/docs/configuration/ai.mdx +49 -10
- package/docs/configuration/customization.mdx +11 -0
- package/docs/configuration/index.mdx +33 -3
- package/docs/configuration/seo.mdx +2 -2
- package/docs/content/components.mdx +30 -3
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/islands.mdx +8 -0
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +17 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +8 -6
- package/package.json +15 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +11 -5
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/ask.ts +12 -7
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +143 -23
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +33 -8
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +24 -7
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +16 -4
- package/src/astro/examples.ts +12 -7
- package/src/astro/generate.ts +393 -189
- package/src/astro/index.ts +5 -1
- package/src/astro/integration.ts +9 -5
- package/src/astro/islands.ts +11 -5
- package/src/astro/markdown-negotiation.ts +2 -2
- package/src/astro/pages.ts +89 -22
- package/src/astro/templates.ts +259 -25
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +131 -38
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +71 -17
- package/src/cli/commands/doctor.ts +2 -2
- package/src/cli/commands/eject.ts +47 -19
- package/src/cli/commands/init.ts +120 -180
- package/src/cli/commands/preview.ts +4 -1
- package/src/cli/commands/validate.ts +44 -2
- package/src/cli/dev-lock.ts +34 -19
- package/src/cli/eject-scripts.ts +72 -0
- package/src/cli/env.ts +15 -5
- package/src/cli/init/questions.ts +158 -0
- package/src/cli/init/scaffold.ts +380 -0
- package/src/cli/required-secrets.ts +2 -1
- package/src/components/content/AccordionItem.astro +23 -4
- package/src/components/content/Badge.astro +3 -1
- package/src/components/content/Card.astro +4 -2
- package/src/components/content/CodeBlock.astro +3 -0
- package/src/components/content/Component.astro +30 -16
- package/src/components/content/Diff.astro +3 -1
- package/src/components/content/Step.astro +10 -1
- package/src/components/content/Tabs.astro +15 -3
- package/src/components/content/Tile.astro +2 -1
- package/src/components/content/Tooltip.astro +3 -1
- package/src/components/content/Update.astro +9 -2
- package/src/components/content/auto-type-table.ts +25 -9
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/diff.ts +12 -6
- package/src/components/content/mermaid-element.ts +10 -2
- package/src/components/index.ts +23 -1
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +68 -12
- package/src/components/islands/base-path.ts +28 -0
- package/src/components/islands/hooks.ts +44 -9
- package/src/components/layout/Banner.astro +12 -3
- package/src/components/layout/Breadcrumbs.astro +2 -1
- package/src/components/layout/Favicon.astro +3 -2
- package/src/components/layout/Header.astro +15 -5
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +13 -4
- package/src/components/layout/NavSelector.astro +2 -1
- package/src/components/layout/NavTree.astro +22 -7
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageFeedback.astro +4 -1
- package/src/components/layout/PageLayout.astro +51 -9
- package/src/components/layout/Pagination.astro +3 -2
- package/src/components/layout/ReferenceLayout.astro +8 -1
- package/src/components/layout/RootLayout.astro +74 -13
- package/src/components/layout/Search.astro +107 -27
- package/src/components/layout/nav-utils.ts +18 -10
- package/src/components/layout/search/algolia.ts +11 -2
- package/src/components/layout/search/endpoint.ts +11 -5
- package/src/components/layout/search/orama-cloud.ts +8 -2
- package/src/components/layout/search/pagefind.ts +3 -0
- package/src/components/layout/search/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +8 -2
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +8 -2
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/components/openapi/snippets.ts +25 -11
- package/src/core/base-path.ts +94 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/component-overrides.ts +103 -74
- package/src/core/config-input.ts +118 -17
- package/src/core/config.ts +8 -5
- package/src/core/content.ts +2 -0
- package/src/core/data.ts +4 -0
- package/src/core/diagnostics.ts +54 -34
- package/src/core/gitignore.ts +4 -1
- package/src/core/graph.ts +166 -88
- package/src/core/i18n-ui.ts +63 -3
- package/src/core/last-modified.ts +15 -6
- package/src/core/links.ts +69 -25
- package/src/core/manifest.ts +62 -45
- package/src/core/nav-diagnostics.ts +1 -1
- package/src/core/navigation.ts +144 -58
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +25 -15
- package/src/core/schema.ts +605 -620
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/github-releases.ts +2 -1
- package/src/core/sources/mdx-remote.ts +76 -63
- package/src/core/sources/normalize.ts +236 -91
- package/src/core/sources/notion.ts +27 -18
- package/src/core/sources/types.ts +2 -0
- package/src/core/tsconfig-aliases.ts +59 -30
- package/src/core/types.ts +6 -1
- package/src/core/ui-packs/ar.ts +1 -0
- package/src/core/ui-packs/bg.ts +1 -0
- package/src/core/ui-packs/bn.ts +1 -0
- package/src/core/ui-packs/ca.ts +1 -0
- package/src/core/ui-packs/cs.ts +1 -0
- package/src/core/ui-packs/da.ts +1 -0
- package/src/core/ui-packs/de.ts +1 -0
- package/src/core/ui-packs/el.ts +1 -0
- package/src/core/ui-packs/es.ts +1 -0
- package/src/core/ui-packs/fa.ts +1 -0
- package/src/core/ui-packs/fi.ts +1 -0
- package/src/core/ui-packs/fr.ts +2 -1
- package/src/core/ui-packs/he.ts +1 -0
- package/src/core/ui-packs/hi.ts +1 -0
- package/src/core/ui-packs/hr.ts +1 -0
- package/src/core/ui-packs/hu.ts +1 -0
- package/src/core/ui-packs/id.ts +1 -0
- package/src/core/ui-packs/it.ts +1 -0
- package/src/core/ui-packs/ja.ts +1 -0
- package/src/core/ui-packs/ko.ts +1 -0
- package/src/core/ui-packs/nl.ts +1 -0
- package/src/core/ui-packs/no.ts +1 -0
- package/src/core/ui-packs/pl.ts +1 -0
- package/src/core/ui-packs/pt-br.ts +1 -0
- package/src/core/ui-packs/pt.ts +1 -0
- package/src/core/ui-packs/ro.ts +1 -0
- package/src/core/ui-packs/ru.ts +1 -0
- package/src/core/ui-packs/sk.ts +1 -0
- package/src/core/ui-packs/sr.ts +1 -0
- package/src/core/ui-packs/sv.ts +1 -0
- package/src/core/ui-packs/th.ts +1 -0
- package/src/core/ui-packs/tr.ts +1 -0
- package/src/core/ui-packs/uk.ts +1 -0
- package/src/core/ui-packs/vi.ts +1 -0
- package/src/core/ui-packs/zh-tw.ts +1 -0
- package/src/core/ui-packs/zh.ts +1 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +25 -2
- package/src/deploy/robots.ts +6 -1
- package/src/deploy/rss.ts +10 -3
- package/src/deploy/sitemap.ts +59 -13
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +60 -0
- package/src/markdown/code-title.ts +11 -14
- package/src/markdown/index.ts +46 -9
- package/src/markdown/inline-code.ts +14 -4
- package/src/markdown/package-commands.ts +10 -4
- package/src/markdown/themes.ts +24 -0
- package/src/openapi/model.ts +15 -5
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +75 -21
- package/src/openapi/render-mdx.ts +11 -6
- package/src/openapi/scalar.ts +32 -16
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +247 -19
- package/src/registry/registry.ts +0 -3
- package/src/search/build.ts +3 -0
- package/src/search/documents.ts +36 -4
- package/src/search/sync/typesense.ts +6 -4
- package/src/seo/jsonld.ts +28 -17
- package/src/theme/entry.ts +85 -20
package/src/deploy/redirects.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withBasePath } from "../core/base-path.ts";
|
|
1
2
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -10,20 +11,42 @@ import type { ResolvedConfig } from "../core/schema.ts";
|
|
|
10
11
|
|
|
11
12
|
type Redirect = ResolvedConfig["redirects"][number];
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Prepend the site-wide `basePath` to each redirect's internal `from`/`to`
|
|
16
|
+
* (both are authored as if mounted at root); external `to` URLs pass through.
|
|
17
|
+
* Idempotent, so re-basing an already-based redirect is safe.
|
|
18
|
+
*/
|
|
19
|
+
export const applyBaseToRedirects = (
|
|
20
|
+
redirects: Redirect[],
|
|
21
|
+
basePath: string
|
|
22
|
+
): Redirect[] =>
|
|
23
|
+
basePath
|
|
24
|
+
? redirects.map((redirect) => ({
|
|
25
|
+
...redirect,
|
|
26
|
+
from: withBasePath(basePath, redirect.from),
|
|
27
|
+
to: withBasePath(basePath, redirect.to),
|
|
28
|
+
}))
|
|
29
|
+
: redirects;
|
|
30
|
+
|
|
13
31
|
/** `_redirects` text (Netlify + Cloudflare Pages): `from to status` per line. */
|
|
14
32
|
export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
|
|
15
33
|
`${redirects
|
|
16
34
|
.map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
|
|
17
35
|
.join("\n")}\n`;
|
|
18
36
|
|
|
19
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* `vercel.json` contents with a `redirects` array. Uses `statusCode` (Vercel's
|
|
39
|
+
* alternative to the boolean `permanent`) so the configured code ships exactly:
|
|
40
|
+
* `permanent` would silently coerce a 301 to 308 and a 302 to 307, diverging
|
|
41
|
+
* from the `_redirects` file, which preserves exact codes.
|
|
42
|
+
*/
|
|
20
43
|
export const buildVercelConfig = (redirects: Redirect[]): string =>
|
|
21
44
|
`${JSON.stringify(
|
|
22
45
|
{
|
|
23
46
|
redirects: redirects.map((redirect) => ({
|
|
24
47
|
destination: redirect.to,
|
|
25
|
-
permanent: redirect.status === 301 || redirect.status === 308,
|
|
26
48
|
source: redirect.from,
|
|
49
|
+
statusCode: redirect.status,
|
|
27
50
|
})),
|
|
28
51
|
},
|
|
29
52
|
null,
|
package/src/deploy/robots.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
1
2
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
3
|
import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
|
|
3
4
|
|
|
@@ -47,7 +48,11 @@ export const buildRobots = (project: BlumeProject): string | null => {
|
|
|
47
48
|
|
|
48
49
|
const { site } = config.deployment;
|
|
49
50
|
if (site && config.seo.sitemap) {
|
|
50
|
-
|
|
51
|
+
const sitemapPath = withBasePath(
|
|
52
|
+
normalizeBasePath(config.deployment.base),
|
|
53
|
+
"/sitemap.xml"
|
|
54
|
+
);
|
|
55
|
+
lines.push("", `Sitemap: ${site.replace(/\/$/u, "")}${sitemapPath}`);
|
|
51
56
|
}
|
|
52
57
|
return `${lines.join("\n")}\n`;
|
|
53
58
|
};
|
package/src/deploy/rss.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
1
2
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
3
|
import type { PageRecord } from "../core/types.ts";
|
|
3
4
|
import { escapeXml } from "./xml.ts";
|
|
@@ -52,6 +53,11 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
|
|
|
52
53
|
return [];
|
|
53
54
|
}
|
|
54
55
|
const base = site.replace(/\/$/u, "");
|
|
56
|
+
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
|
|
57
|
+
// The feed's own `link`/self URL points at the docs root under that base, while
|
|
58
|
+
// `path` stays base-less (it's also the on-disk output location).
|
|
59
|
+
const deployBase = normalizeBasePath(config.deployment.base);
|
|
60
|
+
const rootLink = `${base}${deployBase}`;
|
|
55
61
|
|
|
56
62
|
const feeds: RssFeed[] = [];
|
|
57
63
|
for (const type of rss.types) {
|
|
@@ -70,7 +76,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
|
|
|
70
76
|
description: page.description,
|
|
71
77
|
// Encode like the sitemap does: a route with spaces or non-ASCII
|
|
72
78
|
// must still yield a valid <link>/<guid> URL after XML decoding.
|
|
73
|
-
link: encodeURI(`${base}${page.route}`),
|
|
79
|
+
link: encodeURI(`${base}${withBasePath(deployBase, page.route)}`),
|
|
74
80
|
title: page.title,
|
|
75
81
|
}))
|
|
76
82
|
.toSorted((a, b) => (b.date?.getTime() ?? 0) - (a.date?.getTime() ?? 0))
|
|
@@ -79,7 +85,7 @@ export const buildRssFeeds = (project: BlumeProject): RssFeed[] => {
|
|
|
79
85
|
feeds.push({
|
|
80
86
|
description: config.description,
|
|
81
87
|
items,
|
|
82
|
-
link:
|
|
88
|
+
link: rootLink,
|
|
83
89
|
path: `/${type}/rss.xml`,
|
|
84
90
|
title: `${config.title} — ${capitalize(type)}`,
|
|
85
91
|
type,
|
|
@@ -105,11 +111,12 @@ const renderItem = (item: RssItem): string => {
|
|
|
105
111
|
|
|
106
112
|
/** Serialize a resolved feed into an RSS 2.0 XML document. */
|
|
107
113
|
export const renderRssFeed = (feed: RssFeed): string => {
|
|
114
|
+
const feedSelfHref = `${feed.link}${feed.path}`;
|
|
108
115
|
const channel = [
|
|
109
116
|
` <title>${escapeXml(feed.title)}</title>`,
|
|
110
117
|
` <link>${escapeXml(feed.link)}</link>`,
|
|
111
118
|
` <description>${escapeXml(feed.description ?? feed.title)}</description>`,
|
|
112
|
-
` <atom:link href="${escapeXml(
|
|
119
|
+
` <atom:link href="${escapeXml(feedSelfHref)}" rel="self" type="application/rss+xml" />`,
|
|
113
120
|
];
|
|
114
121
|
const items = feed.items.map(renderItem).join("\n");
|
|
115
122
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
package/src/deploy/sitemap.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
customStaticRoutes,
|
|
3
|
+
discoverPagesSync,
|
|
4
|
+
hasGeneratedChangelog,
|
|
5
|
+
} from "../astro/pages.ts";
|
|
6
|
+
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
1
7
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
2
8
|
import { escapeXml } from "./xml.ts";
|
|
3
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Astro's reserved error routes. A user-authored override (`pages/404.astro`,
|
|
12
|
+
* `pages/500.astro`, or a `404.md` content page — see `writeNotFoundPage` in
|
|
13
|
+
* `astro/generate.ts`) is neither dynamic nor private, so it would otherwise
|
|
14
|
+
* be emitted — but error pages aren't crawlable destinations and must stay out
|
|
15
|
+
* of the sitemap.
|
|
16
|
+
*/
|
|
17
|
+
const ERROR_ROUTES = new Set(["/404", "/500"]);
|
|
18
|
+
|
|
4
19
|
/** A `<lastmod>` element (W3C date) when the page has a valid modified date. */
|
|
5
20
|
const lastmodTag = (value: string | undefined): string => {
|
|
6
21
|
if (!value) {
|
|
@@ -13,9 +28,11 @@ const lastmodTag = (value: string | undefined): string => {
|
|
|
13
28
|
};
|
|
14
29
|
|
|
15
30
|
/**
|
|
16
|
-
* Build a sitemap.xml from the route manifest
|
|
17
|
-
*
|
|
18
|
-
*
|
|
31
|
+
* Build a sitemap.xml from the route manifest plus the routes the manifest
|
|
32
|
+
* can't see: custom `.astro` pages (most importantly a custom landing `/`) and
|
|
33
|
+
* the generated `/changelog` index. Returns null when the sitemap is disabled
|
|
34
|
+
* or no `site` is configured (absolute URLs are required for a valid sitemap).
|
|
35
|
+
* Drafts, hidden, and `noindex` pages are excluded.
|
|
19
36
|
*/
|
|
20
37
|
export const buildSitemap = (project: BlumeProject): string | null => {
|
|
21
38
|
const { site } = project.config.deployment;
|
|
@@ -24,20 +41,49 @@ export const buildSitemap = (project: BlumeProject): string | null => {
|
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
const base = site.replace(/\/$/u, "");
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
|
|
45
|
+
const deployBase = normalizeBasePath(project.config.deployment.base);
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
const urls: string[] = [];
|
|
48
|
+
const pushUrl = (route: string, lastModified?: string): void => {
|
|
32
49
|
// `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
|
|
33
50
|
// then escape XML metacharacters (notably `&`) so a route like
|
|
34
51
|
// `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
|
|
35
52
|
// rejected.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.
|
|
53
|
+
const loc = escapeXml(encodeURI(`${base}${route}`));
|
|
54
|
+
if (seen.has(loc)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
seen.add(loc);
|
|
58
|
+
urls.push(` <url><loc>${loc}</loc>${lastmodTag(lastModified)}</url>`);
|
|
59
|
+
};
|
|
60
|
+
for (const page of project.graph.pages) {
|
|
61
|
+
if (
|
|
62
|
+
page.meta.draft ||
|
|
63
|
+
page.meta.sidebar.hidden ||
|
|
64
|
+
page.meta.seo.noindex ||
|
|
65
|
+
ERROR_ROUTES.has(page.route)
|
|
66
|
+
) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
pushUrl(withBasePath(deployBase, page.route), page.lastModified);
|
|
70
|
+
}
|
|
71
|
+
// Custom `.astro` pages and the generated changelog index mount outside
|
|
72
|
+
// `basePath` (they're injected at their pattern — see `blumeIntegration`), so
|
|
73
|
+
// only the deployment base layers onto their URLs.
|
|
74
|
+
const userPages = project.context.pagesRoot
|
|
75
|
+
? discoverPagesSync(project.context.pagesRoot)
|
|
76
|
+
: [];
|
|
77
|
+
const extraRoutes = customStaticRoutes(userPages).filter(
|
|
78
|
+
(route) => !ERROR_ROUTES.has(route)
|
|
79
|
+
);
|
|
80
|
+
if (hasGeneratedChangelog(project, userPages)) {
|
|
81
|
+
extraRoutes.push("/changelog");
|
|
82
|
+
}
|
|
83
|
+
for (const route of extraRoutes) {
|
|
84
|
+
pushUrl(withBasePath(deployBase, route));
|
|
85
|
+
}
|
|
86
|
+
urls.sort();
|
|
41
87
|
|
|
42
88
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
43
89
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isInternalPath, withComposedBasePath } from "../core/base-path.ts";
|
|
2
|
+
import type { MdastNode } from "./mdast.ts";
|
|
3
|
+
|
|
4
|
+
interface UrlNode extends MdastNode {
|
|
5
|
+
url?: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The slice of Satteri's MDAST visitor context this plugin needs. Nodes are
|
|
10
|
+
* read-only (the tree compiles to an op-stream), so a URL edit is recorded via
|
|
11
|
+
* `setProperty`, not by mutating the node object.
|
|
12
|
+
*/
|
|
13
|
+
interface MdastUrlContext {
|
|
14
|
+
setProperty: (node: unknown, key: "url", value: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A path whose final segment carries a file extension (`/spec.pdf`, `/logo.svg`)
|
|
19
|
+
* — treated as a public asset, which Blume serves from `public/` at the site
|
|
20
|
+
* root and does *not* move under `basePath`. Bare page links (`/guide`) have no
|
|
21
|
+
* extension. The rare dotted route (`/releases/v1.0`) is left un-based here; the
|
|
22
|
+
* build-time link checker still resolves it against the route set.
|
|
23
|
+
*/
|
|
24
|
+
const ASSET_PATH = /\.[a-z0-9]+$/iu;
|
|
25
|
+
|
|
26
|
+
/** Strip any `#fragment`/`?query` so only the path is extension-tested. */
|
|
27
|
+
const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Satteri MDAST plugin that prepends the served-URL base — `deployment.base`
|
|
31
|
+
* layered over the site-wide `basePath` — to root-relative internal page links
|
|
32
|
+
* (`[x](/guide)` -> `/base/docs/guide`), so authors write links as if mounted
|
|
33
|
+
* at root. Idempotent per layer (via `withComposedBasePath`, so a hand-written
|
|
34
|
+
* `/docs/x` isn't double-prefixed) and inert for external URLs, fragments,
|
|
35
|
+
* relative paths, images, and asset links. Only constructed when a base is set
|
|
36
|
+
* (see `markdown/index.ts`).
|
|
37
|
+
*/
|
|
38
|
+
export const baseLinksPlugin = (deployBase: string, basePath: string) => {
|
|
39
|
+
const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
|
|
40
|
+
const { url } = node;
|
|
41
|
+
if (
|
|
42
|
+
typeof url === "string" &&
|
|
43
|
+
isInternalPath(url) &&
|
|
44
|
+
!ASSET_PATH.test(pathOf(url))
|
|
45
|
+
) {
|
|
46
|
+
const next = withComposedBasePath(deployBase, basePath, url);
|
|
47
|
+
if (next !== url) {
|
|
48
|
+
ctx.setProperty(node, "url", next);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
// `link` covers inline links; `definition` covers reference-style link
|
|
53
|
+
// definitions (`[x]: /guide`). `image` is intentionally excluded — images are
|
|
54
|
+
// public assets served at the site root, unaffected by `basePath`.
|
|
55
|
+
return {
|
|
56
|
+
definition: rebase,
|
|
57
|
+
link: rebase,
|
|
58
|
+
name: "blume-base-links",
|
|
59
|
+
};
|
|
60
|
+
};
|
|
@@ -37,6 +37,16 @@ const QUOTED_ATTR = /[\w-]+=(?:"[^"]*"|'[^']*')/gu;
|
|
|
37
37
|
const withoutQuotedAttrs = (raw: string): string =>
|
|
38
38
|
raw.replace(QUOTED_ATTR, " ");
|
|
39
39
|
|
|
40
|
+
// The first bare token is the title (```ts blume.config.ts): a non-empty token
|
|
41
|
+
// that isn't a Shiki line range (`{1,3-5}`), a `key=value` attr, or a reserved
|
|
42
|
+
// `lineNumbers`/`twoslash` keyword.
|
|
43
|
+
const isTitleToken = (token: string): boolean => {
|
|
44
|
+
if (token.length === 0 || token.startsWith("{") || token.includes("=")) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return token !== "lineNumbers" && token !== "twoslash";
|
|
48
|
+
};
|
|
49
|
+
|
|
40
50
|
const parseTitle = (raw: string | undefined): string | undefined => {
|
|
41
51
|
if (!raw) {
|
|
42
52
|
return undefined;
|
|
@@ -46,20 +56,7 @@ const parseTitle = (raw: string | undefined): string | undefined => {
|
|
|
46
56
|
if (attrTitle) {
|
|
47
57
|
return attrTitle;
|
|
48
58
|
}
|
|
49
|
-
|
|
50
|
-
// line ranges (`{1,3-5}`), `key=value` attrs, and the reserved `lineNumbers`
|
|
51
|
-
// and `twoslash` keywords.
|
|
52
|
-
return withoutQuotedAttrs(raw)
|
|
53
|
-
.trim()
|
|
54
|
-
.split(/\s+/u)
|
|
55
|
-
.find(
|
|
56
|
-
(token) =>
|
|
57
|
-
token.length > 0 &&
|
|
58
|
-
token !== "lineNumbers" &&
|
|
59
|
-
token !== "twoslash" &&
|
|
60
|
-
!token.startsWith("{") &&
|
|
61
|
-
!token.includes("=")
|
|
62
|
-
);
|
|
59
|
+
return withoutQuotedAttrs(raw).trim().split(/\s+/u).find(isTitleToken);
|
|
63
60
|
};
|
|
64
61
|
|
|
65
62
|
const hasLineNumbers = (raw: string | undefined): boolean =>
|
package/src/markdown/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "@shikijs/transformers";
|
|
9
9
|
import { codeToHtml } from "shiki";
|
|
10
10
|
|
|
11
|
+
import { baseLinksPlugin } from "./base-links.ts";
|
|
11
12
|
import { codeTitleTransformer } from "./code-title.ts";
|
|
12
13
|
import { directiveToCalloutPlugin } from "./directives.ts";
|
|
13
14
|
import { headingAnchorPlugin } from "./heading-anchors.ts";
|
|
@@ -16,6 +17,8 @@ import { languageIconTransformer } from "./language-icon.ts";
|
|
|
16
17
|
import { mathPlugin } from "./math.ts";
|
|
17
18
|
import { mermaidPlugin } from "./mermaid.ts";
|
|
18
19
|
import { packageInstallPlugin } from "./package-install.ts";
|
|
20
|
+
import { DEFAULT_CODE_THEMES } from "./themes.ts";
|
|
21
|
+
import type { CodeThemes } from "./themes.ts";
|
|
19
22
|
|
|
20
23
|
/** A Shiki transformer, derived from the upstream factories' return type. */
|
|
21
24
|
type ShikiTransformer = ReturnType<typeof transformerNotationDiff>;
|
|
@@ -54,7 +57,7 @@ type HastPlugin = NonNullable<
|
|
|
54
57
|
*/
|
|
55
58
|
const blumeHastPlugins = (options: BlumeMarkdownOptions): HastPlugin[] => {
|
|
56
59
|
const plugins: HastPlugin[] = [
|
|
57
|
-
inlineCodeHighlightPlugin() as unknown as HastPlugin,
|
|
60
|
+
inlineCodeHighlightPlugin(options.codeThemes) as unknown as HastPlugin,
|
|
58
61
|
];
|
|
59
62
|
if (options.headingAnchors !== false) {
|
|
60
63
|
plugins.push(headingAnchorPlugin() as unknown as HastPlugin);
|
|
@@ -99,13 +102,6 @@ export const blumeShikiTransformers = (
|
|
|
99
102
|
return transformers;
|
|
100
103
|
};
|
|
101
104
|
|
|
102
|
-
/**
|
|
103
|
-
* The light/dark Shiki themes Blume highlights with. Kept in lockstep with the
|
|
104
|
-
* generated Astro config's `shikiConfig.themes` so code highlighted outside the
|
|
105
|
-
* Markdown pipeline (via {@link highlightCode}) matches fenced code exactly.
|
|
106
|
-
*/
|
|
107
|
-
const CODE_THEMES = { dark: "github-dark", light: "github-light" } as const;
|
|
108
|
-
|
|
109
105
|
const escapeHtml = (value: string): string =>
|
|
110
106
|
value
|
|
111
107
|
.replaceAll("&", "&")
|
|
@@ -147,6 +143,11 @@ const languageAttrTransformer = (lang: string): ShikiTransformer =>
|
|
|
147
143
|
export interface HighlightCodeOptions extends BlumeShikiOptions {
|
|
148
144
|
/** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
|
|
149
145
|
className?: string;
|
|
146
|
+
/**
|
|
147
|
+
* Light/dark Shiki themes (`markdown.codeBlocks.theme`). Defaults to the same
|
|
148
|
+
* github pair fenced code uses, so out-of-pipeline code stays in lockstep.
|
|
149
|
+
*/
|
|
150
|
+
themes?: CodeThemes;
|
|
150
151
|
/** Header title (a filename), matching a fence's `title="..."` meta. */
|
|
151
152
|
title?: string;
|
|
152
153
|
}
|
|
@@ -176,7 +177,7 @@ export const highlightCode = async (
|
|
|
176
177
|
meta: options.title
|
|
177
178
|
? { __raw: `title="${options.title.replaceAll('"', "")}"` }
|
|
178
179
|
: undefined,
|
|
179
|
-
themes:
|
|
180
|
+
themes: options.themes ?? DEFAULT_CODE_THEMES,
|
|
180
181
|
transformers: [
|
|
181
182
|
...blumeShikiTransformers({ icons: options.icons }),
|
|
182
183
|
astroCodeClassTransformer(options.className),
|
|
@@ -200,18 +201,53 @@ const FEATURES = { subscript: true, superscript: true };
|
|
|
200
201
|
|
|
201
202
|
/** Options shared by both processors. */
|
|
202
203
|
export interface BlumeMarkdownOptions {
|
|
204
|
+
/**
|
|
205
|
+
* Light/dark Shiki themes for inline `` `code`{:lang} `` highlighting
|
|
206
|
+
* (`markdown.codeBlocks.theme`). Defaults to the github pair fenced code uses.
|
|
207
|
+
*/
|
|
208
|
+
codeThemes?: CodeThemes;
|
|
203
209
|
/**
|
|
204
210
|
* Wrap `<h2>`–`<h6>` in self-linking anchors (`markdown.headingAnchors`).
|
|
205
211
|
* On unless explicitly `false`.
|
|
206
212
|
*/
|
|
207
213
|
headingAnchors?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* Site-wide route mount point (`""` or `/seg`). When set, root-relative
|
|
216
|
+
* internal page links in content are rewritten under it, so authors write
|
|
217
|
+
* links as if mounted at root.
|
|
218
|
+
*/
|
|
219
|
+
basePath?: string;
|
|
220
|
+
/**
|
|
221
|
+
* Astro's `deployment.base` subdirectory (`""` or `/seg`), layered on top of
|
|
222
|
+
* `basePath` when links are rewritten. Kept separate so a hand-written
|
|
223
|
+
* `basePath` link isn't double-prefixed (see `withComposedBasePath`).
|
|
224
|
+
*/
|
|
225
|
+
deployBase?: string;
|
|
208
226
|
}
|
|
209
227
|
|
|
228
|
+
/**
|
|
229
|
+
* MDAST plugins that apply to both `.md` and `.mdx`. Currently just the
|
|
230
|
+
* base-path link rewrite, added only when a `basePath` or `deployBase` is
|
|
231
|
+
* configured.
|
|
232
|
+
*/
|
|
233
|
+
const blumeSharedMdastPlugins = (
|
|
234
|
+
options: BlumeMarkdownOptions
|
|
235
|
+
): MdastPlugin[] =>
|
|
236
|
+
options.basePath || options.deployBase
|
|
237
|
+
? [
|
|
238
|
+
baseLinksPlugin(
|
|
239
|
+
options.deployBase ?? "",
|
|
240
|
+
options.basePath ?? ""
|
|
241
|
+
) as unknown as MdastPlugin,
|
|
242
|
+
]
|
|
243
|
+
: [];
|
|
244
|
+
|
|
210
245
|
/** Sätteri processor for plain `.md`, with Blume's curated feature set. */
|
|
211
246
|
export const blumeMarkdownProcessor = (options: BlumeMarkdownOptions = {}) =>
|
|
212
247
|
satteri({
|
|
213
248
|
features: { ...FEATURES },
|
|
214
249
|
hastPlugins: blumeHastPlugins(options),
|
|
250
|
+
mdastPlugins: blumeSharedMdastPlugins(options),
|
|
215
251
|
});
|
|
216
252
|
|
|
217
253
|
export type BlumeMdxOptions = BlumeMarkdownOptions;
|
|
@@ -246,5 +282,6 @@ export const blumeMdxProcessor = (options: BlumeMdxOptions = {}) =>
|
|
|
246
282
|
directiveToCalloutPlugin(),
|
|
247
283
|
mermaidPlugin(),
|
|
248
284
|
mathPlugin(),
|
|
285
|
+
...blumeSharedMdastPlugins(options),
|
|
249
286
|
] as unknown as MdastPlugin[],
|
|
250
287
|
});
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* added to the pipeline at all).
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { DEFAULT_CODE_THEMES } from "./themes.ts";
|
|
16
|
+
import type { CodeThemes } from "./themes.ts";
|
|
17
|
+
|
|
15
18
|
/** A minimal hast node (avoids a hast type dependency). */
|
|
16
19
|
interface HastNode {
|
|
17
20
|
children?: HastNode[];
|
|
@@ -72,7 +75,9 @@ const loadHighlighter = async (): Promise<InlineHighlighter> => {
|
|
|
72
75
|
};
|
|
73
76
|
|
|
74
77
|
/** Build the plugin. Highlights inline `` `code{:lang}` `` snippets. */
|
|
75
|
-
export const inlineCodeHighlightPlugin = (
|
|
78
|
+
export const inlineCodeHighlightPlugin = (
|
|
79
|
+
themes: CodeThemes = DEFAULT_CODE_THEMES
|
|
80
|
+
): InlineCodePlugin => ({
|
|
76
81
|
element: {
|
|
77
82
|
filter: ["code"],
|
|
78
83
|
async visit(node, ctx) {
|
|
@@ -90,7 +95,7 @@ export const inlineCodeHighlightPlugin = (): InlineCodePlugin => ({
|
|
|
90
95
|
defaultColor: false,
|
|
91
96
|
lang: parsed.lang,
|
|
92
97
|
structure: "inline",
|
|
93
|
-
themes
|
|
98
|
+
themes,
|
|
94
99
|
});
|
|
95
100
|
return {
|
|
96
101
|
children: root.children,
|
|
@@ -99,8 +104,13 @@ export const inlineCodeHighlightPlugin = (): InlineCodePlugin => ({
|
|
|
99
104
|
type: "element",
|
|
100
105
|
};
|
|
101
106
|
} catch {
|
|
102
|
-
// Unknown language or load failure:
|
|
103
|
-
//
|
|
107
|
+
// Unknown language or load failure: still strip the marker — the
|
|
108
|
+
// literal `{:lang}` must not ship in the page — and fall back to
|
|
109
|
+
// plain, unhighlighted inline code.
|
|
110
|
+
return {
|
|
111
|
+
...node,
|
|
112
|
+
children: [{ type: "text", value: parsed.code }],
|
|
113
|
+
};
|
|
104
114
|
}
|
|
105
115
|
},
|
|
106
116
|
},
|
|
@@ -149,10 +149,16 @@ const buildCommand = (manager: PackageManager, intent: Intent): string => {
|
|
|
149
149
|
return `${manager} dlx ${args}`;
|
|
150
150
|
}
|
|
151
151
|
case "ci": {
|
|
152
|
-
// `npm ci` maps to a frozen, lockfile-faithful install elsewhere.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
// `npm ci` maps to a frozen, lockfile-faithful install elsewhere. Yarn
|
|
153
|
+
// Berry's flag is `--immutable` (`--frozen-lockfile` was removed in
|
|
154
|
+
// Yarn 4), matching the Berry-only `yarn dlx` the `exec` case emits.
|
|
155
|
+
if (manager === "npm") {
|
|
156
|
+
return "npm ci";
|
|
157
|
+
}
|
|
158
|
+
if (manager === "yarn") {
|
|
159
|
+
return "yarn install --immutable";
|
|
160
|
+
}
|
|
161
|
+
return `${manager} install --frozen-lockfile`;
|
|
156
162
|
}
|
|
157
163
|
case "remove": {
|
|
158
164
|
if (manager === "npm") {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The light/dark Shiki themes Blume highlights code with. Every Shiki surface —
|
|
3
|
+
* fenced code (the generated Astro `shikiConfig.themes`), inline `` `code`{:lang} ``,
|
|
4
|
+
* out-of-pipeline `highlightCode`, and `<Diff>` — resolves to the same pair so a
|
|
5
|
+
* project's `markdown.codeBlocks.theme` shifts them all in lockstep. This is the
|
|
6
|
+
* single home for the github fallback used when nothing is configured.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A light/dark Shiki theme pair (`markdown.codeBlocks.theme`). A `type` (not an
|
|
11
|
+
* `interface`) so it keeps the implicit index signature Shiki's `themes`
|
|
12
|
+
* parameter (`Partial<Record<string, …>>`) expects.
|
|
13
|
+
*/
|
|
14
|
+
// oxlint-disable-next-line typescript/consistent-type-definitions -- interface loses the implicit index signature Shiki's `themes` param needs
|
|
15
|
+
export type CodeThemes = {
|
|
16
|
+
dark: string;
|
|
17
|
+
light: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The default pair, used when `markdown.codeBlocks.theme` is unset. */
|
|
21
|
+
export const DEFAULT_CODE_THEMES: CodeThemes = {
|
|
22
|
+
dark: "github-dark",
|
|
23
|
+
light: "github-light",
|
|
24
|
+
};
|
package/src/openapi/model.ts
CHANGED
|
@@ -104,22 +104,31 @@ const isOperation = (value: unknown): value is OperationObject =>
|
|
|
104
104
|
/**
|
|
105
105
|
* Flatten a 3.1 document into a route-mapped operation list and its ordered
|
|
106
106
|
* tags. Operations inherit the first tag they declare; keys are de-duplicated so
|
|
107
|
-
* a repeated `operationId` still yields distinct routes.
|
|
107
|
+
* a repeated `operationId` still yields distinct routes. `warnings` reports
|
|
108
|
+
* anything skipped (a `$ref` path item), so missing operations aren't silent.
|
|
108
109
|
*/
|
|
109
110
|
export const extractOperations = (
|
|
110
111
|
document: ApiDocument,
|
|
111
112
|
baseRoute: string
|
|
112
|
-
): { operations: ApiOperationRef[]; tags: ApiTagRef[] } => {
|
|
113
|
+
): { operations: ApiOperationRef[]; tags: ApiTagRef[]; warnings: string[] } => {
|
|
113
114
|
const operations: ApiOperationRef[] = [];
|
|
114
115
|
const tagOrder: string[] = [];
|
|
116
|
+
const tagsSeen = new Set<string>();
|
|
115
117
|
const tagMeta = new Map(
|
|
116
118
|
(document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
|
|
117
119
|
);
|
|
118
120
|
const seen = new Set<string>();
|
|
121
|
+
const warnings: string[] = [];
|
|
119
122
|
|
|
120
123
|
for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
|
|
121
124
|
const item = rawItem as PathItemObject | undefined;
|
|
122
|
-
if (!item
|
|
125
|
+
if (!item) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if ("$ref" in item) {
|
|
129
|
+
warnings.push(
|
|
130
|
+
`Path "${path}" is a $ref to a shared path item; referenced path items are not resolved, so its operations are missing from the reference. Inline the path item under "paths" to render it.`
|
|
131
|
+
);
|
|
123
132
|
continue;
|
|
124
133
|
}
|
|
125
134
|
for (const method of HTTP_METHODS) {
|
|
@@ -129,7 +138,8 @@ export const extractOperations = (
|
|
|
129
138
|
}
|
|
130
139
|
const tag = operation.tags?.[0] ?? UNTAGGED;
|
|
131
140
|
const tagSlug = slugify(tag) || "operations";
|
|
132
|
-
if (!
|
|
141
|
+
if (!tagsSeen.has(tag)) {
|
|
142
|
+
tagsSeen.add(tag);
|
|
133
143
|
tagOrder.push(tag);
|
|
134
144
|
}
|
|
135
145
|
let key = operationKey(method, path, operation.operationId);
|
|
@@ -159,7 +169,7 @@ export const extractOperations = (
|
|
|
159
169
|
slug: slugify(name) || "operations",
|
|
160
170
|
}));
|
|
161
171
|
|
|
162
|
-
return { operations, tags };
|
|
172
|
+
return { operations, tags, warnings };
|
|
163
173
|
};
|
|
164
174
|
|
|
165
175
|
/** Resolve the operation object for a ref out of its document. */
|
package/src/openapi/parse.ts
CHANGED
|
@@ -47,6 +47,19 @@ export interface ParsedSpec {
|
|
|
47
47
|
warnings: string[];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The spec was read successfully but its contents aren't an OpenAPI document
|
|
52
|
+
* (an empty file, or YAML that parses to a scalar or list — say, a README
|
|
53
|
+
* pointed at by mistake). Kept distinct from read/fetch failures so callers
|
|
54
|
+
* can suggest fixing the file instead of checking reachability.
|
|
55
|
+
*/
|
|
56
|
+
export class InvalidSpecError extends Error {
|
|
57
|
+
constructor(message: string) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "InvalidSpecError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
/** Where and whether to cache a remote spec's text between runs. */
|
|
51
64
|
export interface SpecFetchOptions {
|
|
52
65
|
/** Dir for a last-good on-disk copy of a remote spec (offline fallback). */
|
|
@@ -235,5 +248,13 @@ export const parseSpec = async (
|
|
|
235
248
|
const { text, warnings } = await readSpecText(spec, root, options);
|
|
236
249
|
const normalized = normalize(text);
|
|
237
250
|
const { specification } = upgrade(normalized);
|
|
251
|
+
// `normalize` yields undefined for anything that isn't a YAML/JSON mapping
|
|
252
|
+
// (empty file, scalar, list) and `upgrade(undefined)` yields a null
|
|
253
|
+
// specification — reject it here so the renderer never sees a non-document.
|
|
254
|
+
if (specification === null || typeof specification !== "object") {
|
|
255
|
+
throw new InvalidSpecError(
|
|
256
|
+
`${spec} is not a valid OpenAPI document (expected a YAML or JSON object).`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
238
259
|
return { document: specification as ApiDocument, warnings };
|
|
239
260
|
};
|