blume 0.7.0 → 1.0.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 +666 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/cli/index.js +1852 -380
- package/dist/cli/index.js.map +98 -91
- 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 +9 -0
- package/dist/types/core/config-input.d.ts +47 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +7 -0
- package/dist/types/core/i18n-ui.d.ts +526 -132
- package/dist/types/core/schema.d.ts +293 -146
- package/dist/types/index.d.ts +1 -0
- package/dist/types/openapi/references.d.ts +60 -0
- package/dist/types/seo/x-handle.d.ts +12 -0
- package/docs/01-quickstart.mdx +5 -2
- package/docs/02-deployment.mdx +8 -8
- package/docs/03-faq.mdx +46 -16
- package/docs/advanced/api-reference.mdx +1 -1
- package/docs/advanced/changelog.mdx +1 -1
- 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/export.mdx +1 -1
- package/docs/configuration/index.mdx +27 -3
- package/docs/configuration/seo.mdx +35 -5
- package/docs/content/components.mdx +2 -2
- package/docs/content/i18n.mdx +1 -1
- package/docs/content/navigation.mdx +3 -3
- package/docs/content/sources.mdx +1 -1
- package/docs/content/syntax.mdx +6 -4
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +9 -7
- package/docs/reference/frontmatter.mdx +1 -1
- package/package.json +22 -4
- package/skills/blume/SKILL.md +5 -3
- package/skills/blume-update-docs/SKILL.md +3 -2
- package/src/ai/agent-readability.ts +9 -8
- package/src/ai/ask-context.ts +7 -2
- package/src/ai/ask-data.ts +3 -0
- package/src/ai/component-markdown.ts +461 -0
- package/src/ai/llms.ts +135 -26
- package/src/ai/markdown.ts +35 -6
- package/src/ai/mcp/data.ts +25 -4
- package/src/ai/mcp/discovery.ts +10 -3
- package/src/ai/mcp/server.ts +21 -7
- package/src/ai/mcp/tools.ts +1 -1
- package/src/ai/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +77 -45
- package/src/astro/integration.ts +1 -1
- package/src/astro/markdown-negotiation.ts +1 -1
- package/src/astro/pages.ts +81 -19
- package/src/astro/templates.ts +150 -19
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +120 -23
- package/src/cli/commands/check.ts +1 -1
- package/src/cli/commands/dev.ts +26 -5
- 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 +43 -2
- package/src/cli/dev-lock.ts +8 -4
- 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/internal-error.ts +9 -4
- package/src/cli/prepare.ts +3 -2
- package/src/components/Icon.astro +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/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 +7 -1
- package/src/components/content/base-href.ts +33 -0
- package/src/components/content/changelog-element.ts +9 -2
- package/src/components/content/mermaid-element.ts +7 -2
- package/src/components/islands/AskAI.astro +5 -2
- package/src/components/islands/ask-ai.tsx +86 -11
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Breadcrumbs.astro +11 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +19 -5
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +85 -9
- package/src/components/layout/Pagination.astro +10 -4
- package/src/components/layout/ReferenceLayout.astro +20 -2
- package/src/components/layout/RootLayout.astro +142 -12
- package/src/components/layout/Search.astro +117 -27
- 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/types.ts +5 -1
- package/src/components/layout/search/typesense.ts +4 -1
- package/src/components/layout/toc-element.ts +1 -1
- package/src/components/openapi/ApiTagOperations.astro +2 -1
- package/src/components/openapi/Operation.astro +47 -40
- package/src/components/openapi/RequestPanel.astro +1 -1
- package/src/components/openapi/helpers.ts +71 -3
- package/src/components/openapi/panel.ts +1 -1
- package/src/core/base-path.ts +24 -0
- package/src/core/builtin-tags.ts +2 -0
- package/src/core/config-input.ts +48 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +4 -0
- package/src/core/frontmatter.ts +7 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +54 -0
- package/src/core/i18n.ts +16 -8
- package/src/core/last-modified.ts +13 -6
- package/src/core/links.ts +32 -8
- package/src/core/navigation.ts +29 -4
- package/src/core/package-json.ts +17 -2
- package/src/core/project-graph.ts +15 -6
- package/src/core/schema.ts +71 -2
- package/src/core/sources/assets.ts +6 -1
- package/src/core/sources/filesystem.ts +4 -0
- package/src/core/sources/mdx-remote.ts +23 -14
- package/src/core/sources/normalize.ts +152 -50
- package/src/core/sources/notion.ts +8 -8
- package/src/core/ui-packs/ar.ts +8 -0
- package/src/core/ui-packs/bg.ts +8 -0
- package/src/core/ui-packs/bn.ts +8 -0
- package/src/core/ui-packs/ca.ts +8 -0
- package/src/core/ui-packs/cs.ts +8 -0
- package/src/core/ui-packs/da.ts +8 -0
- package/src/core/ui-packs/de.ts +8 -0
- package/src/core/ui-packs/el.ts +8 -0
- package/src/core/ui-packs/es.ts +8 -0
- package/src/core/ui-packs/fa.ts +8 -0
- package/src/core/ui-packs/fi.ts +8 -0
- package/src/core/ui-packs/fr.ts +9 -1
- package/src/core/ui-packs/he.ts +8 -0
- package/src/core/ui-packs/hi.ts +8 -0
- package/src/core/ui-packs/hr.ts +8 -0
- package/src/core/ui-packs/hu.ts +8 -0
- package/src/core/ui-packs/id.ts +8 -0
- package/src/core/ui-packs/it.ts +8 -0
- package/src/core/ui-packs/ja.ts +8 -0
- package/src/core/ui-packs/ko.ts +8 -0
- package/src/core/ui-packs/nl.ts +8 -0
- package/src/core/ui-packs/no.ts +8 -0
- package/src/core/ui-packs/pl.ts +8 -0
- package/src/core/ui-packs/pt-br.ts +8 -0
- package/src/core/ui-packs/pt.ts +8 -0
- package/src/core/ui-packs/ro.ts +8 -0
- package/src/core/ui-packs/ru.ts +8 -0
- package/src/core/ui-packs/sk.ts +8 -0
- package/src/core/ui-packs/sr.ts +8 -0
- package/src/core/ui-packs/sv.ts +8 -0
- package/src/core/ui-packs/th.ts +8 -0
- package/src/core/ui-packs/tr.ts +8 -0
- package/src/core/ui-packs/uk.ts +8 -0
- package/src/core/ui-packs/vi.ts +8 -0
- package/src/core/ui-packs/zh-tw.ts +8 -0
- package/src/core/ui-packs/zh.ts +8 -0
- package/src/deploy/adapter-output.ts +18 -8
- package/src/deploy/redirects.ts +7 -2
- package/src/deploy/sitemap.ts +53 -11
- package/src/index.ts +5 -0
- package/src/markdown/base-links.ts +10 -8
- package/src/markdown/index.ts +15 -3
- package/src/markdown/inline-code.ts +7 -2
- package/src/markdown/package-commands.ts +10 -4
- package/src/og/card.ts +4 -2
- package/src/og/dimensions.ts +12 -0
- package/src/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- package/src/openapi/render-mdx.ts +62 -1
- package/src/openapi/source.ts +59 -10
- package/src/registry/eject.ts +184 -12
- package/src/registry/registry.ts +0 -3
- package/src/search/documents.ts +34 -2
- package/src/seo/jsonld.ts +20 -13
- package/src/seo/x-handle.ts +18 -0
package/src/core/ui-packs/vi.ts
CHANGED
|
@@ -23,6 +23,11 @@ export const vi: UIStringsOverride = {
|
|
|
23
23
|
send: "Gửi",
|
|
24
24
|
title: "Hỏi AI",
|
|
25
25
|
},
|
|
26
|
+
banner: { dismiss: "Đóng thông báo" },
|
|
27
|
+
changelog: {
|
|
28
|
+
description: "Cập nhật sản phẩm và ghi chú phát hành.",
|
|
29
|
+
title: "Nhật ký thay đổi",
|
|
30
|
+
},
|
|
26
31
|
feedback: {
|
|
27
32
|
no: "Không",
|
|
28
33
|
question: "Trang này có hữu ích không?",
|
|
@@ -30,13 +35,16 @@ export const vi: UIStringsOverride = {
|
|
|
30
35
|
yes: "Có",
|
|
31
36
|
},
|
|
32
37
|
languageSwitcher: { label: "Ngôn ngữ", untranslated: "Chưa dịch" },
|
|
38
|
+
nav: { breadcrumb: "Đường dẫn điều hướng" },
|
|
33
39
|
page: {
|
|
34
40
|
lastUpdated: "Cập nhật lần cuối",
|
|
35
41
|
next: "Tiếp theo",
|
|
42
|
+
pagination: "Phân trang",
|
|
36
43
|
previous: "Trước",
|
|
37
44
|
skipToContent: "Chuyển đến nội dung",
|
|
38
45
|
},
|
|
39
46
|
search: {
|
|
47
|
+
all: "Tất cả",
|
|
40
48
|
button: "Tìm kiếm",
|
|
41
49
|
devOnly: "Tìm kiếm có sẵn trong bản dựng production.",
|
|
42
50
|
label: "Tìm kiếm tài liệu",
|
|
@@ -23,6 +23,11 @@ export const zhTW: UIStringsOverride = {
|
|
|
23
23
|
send: "傳送",
|
|
24
24
|
title: "向 AI 提問",
|
|
25
25
|
},
|
|
26
|
+
banner: { dismiss: "關閉公告" },
|
|
27
|
+
changelog: {
|
|
28
|
+
description: "產品更新與版本說明。",
|
|
29
|
+
title: "更新日誌",
|
|
30
|
+
},
|
|
26
31
|
feedback: {
|
|
27
32
|
no: "沒有幫助",
|
|
28
33
|
question: "這個頁面有幫助嗎?",
|
|
@@ -30,13 +35,16 @@ export const zhTW: UIStringsOverride = {
|
|
|
30
35
|
yes: "有幫助",
|
|
31
36
|
},
|
|
32
37
|
languageSwitcher: { label: "語言", untranslated: "未翻譯" },
|
|
38
|
+
nav: { breadcrumb: "麵包屑導覽" },
|
|
33
39
|
page: {
|
|
34
40
|
lastUpdated: "最後更新於",
|
|
35
41
|
next: "下一頁",
|
|
42
|
+
pagination: "分頁",
|
|
36
43
|
previous: "上一頁",
|
|
37
44
|
skipToContent: "跳至內容",
|
|
38
45
|
},
|
|
39
46
|
search: {
|
|
47
|
+
all: "全部",
|
|
40
48
|
button: "搜尋",
|
|
41
49
|
devOnly: "搜尋在正式版組建中可用。",
|
|
42
50
|
label: "搜尋文件",
|
package/src/core/ui-packs/zh.ts
CHANGED
|
@@ -23,6 +23,11 @@ export const zh: UIStringsOverride = {
|
|
|
23
23
|
send: "发送",
|
|
24
24
|
title: "向 AI 提问",
|
|
25
25
|
},
|
|
26
|
+
banner: { dismiss: "关闭公告" },
|
|
27
|
+
changelog: {
|
|
28
|
+
description: "产品更新与版本说明。",
|
|
29
|
+
title: "更新日志",
|
|
30
|
+
},
|
|
26
31
|
feedback: {
|
|
27
32
|
no: "没有帮助",
|
|
28
33
|
question: "这个页面有帮助吗?",
|
|
@@ -30,13 +35,16 @@ export const zh: UIStringsOverride = {
|
|
|
30
35
|
yes: "有帮助",
|
|
31
36
|
},
|
|
32
37
|
languageSwitcher: { label: "语言", untranslated: "未翻译" },
|
|
38
|
+
nav: { breadcrumb: "面包屑导航" },
|
|
33
39
|
page: {
|
|
34
40
|
lastUpdated: "最后更新于",
|
|
35
41
|
next: "下一页",
|
|
42
|
+
pagination: "分页",
|
|
36
43
|
previous: "上一页",
|
|
37
44
|
skipToContent: "跳到内容",
|
|
38
45
|
},
|
|
39
46
|
search: {
|
|
47
|
+
all: "全部",
|
|
40
48
|
button: "搜索",
|
|
41
49
|
devOnly: "搜索在生产构建中可用。",
|
|
42
50
|
label: "搜索文档",
|
|
@@ -17,12 +17,15 @@ type Adapter = NonNullable<ResolvedConfig["deployment"]["adapter"]>;
|
|
|
17
17
|
*
|
|
18
18
|
* `vercel` writes a Build Output API v3 tree at `.vercel/output`; only that
|
|
19
19
|
* subtree is moved, so a `vercel pull`-ed `.vercel/project.json` sitting at the
|
|
20
|
-
* project root survives the relocation. `netlify`
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* project root survives the relocation. `netlify` writes a Frameworks API tree
|
|
21
|
+
* at `.netlify/v1` (its `.netlify/build` sibling is only the intermediate SSR
|
|
22
|
+
* bundle, already traced into `v1/functions`); only `v1` is moved, so the
|
|
23
|
+
* `.netlify/state.json` written by `netlify link` survives too. `node` and
|
|
24
|
+
* `cloudflare` emit into `dist/` (already at the project root), so they are
|
|
25
|
+
* absent here and need no relocation.
|
|
23
26
|
*/
|
|
24
27
|
export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
|
|
25
|
-
netlify: ".netlify",
|
|
28
|
+
netlify: ".netlify/v1",
|
|
26
29
|
vercel: ".vercel/output",
|
|
27
30
|
};
|
|
28
31
|
|
|
@@ -30,7 +33,10 @@ export const ADAPTER_OUTPUT_PATHS: Partial<Record<Adapter, string>> = {
|
|
|
30
33
|
* Directory whose contents the deploy platform serves as static files. Build
|
|
31
34
|
* artifacts (robots.txt, sitemap.xml, llms.txt, …) must be written here to be
|
|
32
35
|
* served. For a Vercel server build that is the adapter's
|
|
33
|
-
* `.vercel/output/static`;
|
|
36
|
+
* `.vercel/output/static`; for a Node server build it is Astro's
|
|
37
|
+
* `build.client` dir (`dist/client/`), the only directory the standalone
|
|
38
|
+
* server's static handler reads from. Netlify publishes `dist/` itself and
|
|
39
|
+
* Cloudflare serves the `outDir` root, so every other build serves `dist/`.
|
|
34
40
|
*/
|
|
35
41
|
export const deployStaticDir = (
|
|
36
42
|
config: ResolvedConfig,
|
|
@@ -40,7 +46,11 @@ export const deployStaticDir = (
|
|
|
40
46
|
if (output === "server" && adapter === "vercel") {
|
|
41
47
|
return join(context.root, ".vercel", "output", "static");
|
|
42
48
|
}
|
|
43
|
-
|
|
49
|
+
const dist = context.distDir ?? join(context.root, "dist");
|
|
50
|
+
if (output === "server" && adapter === "node") {
|
|
51
|
+
return join(dist, "client");
|
|
52
|
+
}
|
|
53
|
+
return dist;
|
|
44
54
|
};
|
|
45
55
|
|
|
46
56
|
/** Outcome of {@link surfaceAdapterOutput}, for logging and `.gitignore`. */
|
|
@@ -76,7 +86,7 @@ export const surfaceAdapterOutput = async (
|
|
|
76
86
|
await cp(from, to, { recursive: true });
|
|
77
87
|
await rm(from, { force: true, recursive: true });
|
|
78
88
|
// The `.gitignore` entry is the surfaced top-level dir (`.vercel`/`.netlify`),
|
|
79
|
-
// never the moved sub-path —
|
|
80
|
-
// too and must also be ignored.
|
|
89
|
+
// never the moved sub-path — the platform's own state (`.vercel/project.json`,
|
|
90
|
+
// `.netlify/state.json`) lives there too and must also be ignored.
|
|
81
91
|
return { from, ignore: `${rel.split("/")[0]}/`, moved: true, to };
|
|
82
92
|
};
|
package/src/deploy/redirects.ts
CHANGED
|
@@ -34,14 +34,19 @@ export const buildNetlifyRedirects = (redirects: Redirect[]): string =>
|
|
|
34
34
|
.map((redirect) => `${redirect.from} ${redirect.to} ${redirect.status}`)
|
|
35
35
|
.join("\n")}\n`;
|
|
36
36
|
|
|
37
|
-
/**
|
|
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
|
+
*/
|
|
38
43
|
export const buildVercelConfig = (redirects: Redirect[]): string =>
|
|
39
44
|
`${JSON.stringify(
|
|
40
45
|
{
|
|
41
46
|
redirects: redirects.map((redirect) => ({
|
|
42
47
|
destination: redirect.to,
|
|
43
|
-
permanent: redirect.status === 301 || redirect.status === 308,
|
|
44
48
|
source: redirect.from,
|
|
49
|
+
statusCode: redirect.status,
|
|
45
50
|
})),
|
|
46
51
|
},
|
|
47
52
|
null,
|
package/src/deploy/sitemap.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
customStaticRoutes,
|
|
3
|
+
discoverPagesSync,
|
|
4
|
+
hasGeneratedChangelog,
|
|
5
|
+
} from "../astro/pages.ts";
|
|
1
6
|
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
2
7
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
3
8
|
import { escapeXml } from "./xml.ts";
|
|
4
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
|
+
|
|
5
19
|
/** A `<lastmod>` element (W3C date) when the page has a valid modified date. */
|
|
6
20
|
const lastmodTag = (value: string | undefined): string => {
|
|
7
21
|
if (!value) {
|
|
@@ -14,9 +28,11 @@ const lastmodTag = (value: string | undefined): string => {
|
|
|
14
28
|
};
|
|
15
29
|
|
|
16
30
|
/**
|
|
17
|
-
* Build a sitemap.xml from the route manifest
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
20
36
|
*/
|
|
21
37
|
export const buildSitemap = (project: BlumeProject): string | null => {
|
|
22
38
|
const { site } = project.config.deployment;
|
|
@@ -27,19 +43,45 @@ export const buildSitemap = (project: BlumeProject): string | null => {
|
|
|
27
43
|
const base = site.replace(/\/$/u, "");
|
|
28
44
|
// Routes carry `basePath`; a `deployment.base` subdirectory is layered on top.
|
|
29
45
|
const deployBase = normalizeBasePath(project.config.deployment.base);
|
|
46
|
+
const seen = new Set<string>();
|
|
30
47
|
const urls: string[] = [];
|
|
31
|
-
|
|
32
|
-
if (page.meta.draft || page.meta.sidebar.hidden || page.meta.seo.noindex) {
|
|
33
|
-
continue;
|
|
34
|
-
}
|
|
48
|
+
const pushUrl = (route: string, lastModified?: string): void => {
|
|
35
49
|
// `<loc>` must be a well-formed, XML-escaped URL: percent-encode the path,
|
|
36
50
|
// then escape XML metacharacters (notably `&`) so a route like
|
|
37
51
|
// `/Tips & Tricks` doesn't produce invalid XML that gets the whole sitemap
|
|
38
52
|
// rejected.
|
|
39
|
-
const loc = escapeXml(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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));
|
|
43
85
|
}
|
|
44
86
|
urls.sort();
|
|
45
87
|
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isInternalPath,
|
|
1
|
+
import { isInternalPath, withComposedBasePath } from "../core/base-path.ts";
|
|
2
2
|
import type { MdastNode } from "./mdast.ts";
|
|
3
3
|
|
|
4
4
|
interface UrlNode extends MdastNode {
|
|
@@ -27,13 +27,15 @@ const ASSET_PATH = /\.[a-z0-9]+$/iu;
|
|
|
27
27
|
const pathOf = (url: string): string => url.replace(/[#?].*$/u, "");
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
|
-
* Satteri MDAST plugin that prepends the
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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`).
|
|
35
37
|
*/
|
|
36
|
-
export const baseLinksPlugin = (basePath: string) => {
|
|
38
|
+
export const baseLinksPlugin = (deployBase: string, basePath: string) => {
|
|
37
39
|
const rebase = (node: UrlNode, ctx: MdastUrlContext): void => {
|
|
38
40
|
const { url } = node;
|
|
39
41
|
if (
|
|
@@ -41,7 +43,7 @@ export const baseLinksPlugin = (basePath: string) => {
|
|
|
41
43
|
isInternalPath(url) &&
|
|
42
44
|
!ASSET_PATH.test(pathOf(url))
|
|
43
45
|
) {
|
|
44
|
-
const next =
|
|
46
|
+
const next = withComposedBasePath(deployBase, basePath, url);
|
|
45
47
|
if (next !== url) {
|
|
46
48
|
ctx.setProperty(node, "url", next);
|
|
47
49
|
}
|
package/src/markdown/index.ts
CHANGED
|
@@ -217,17 +217,29 @@ export interface BlumeMarkdownOptions {
|
|
|
217
217
|
* links as if mounted at root.
|
|
218
218
|
*/
|
|
219
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;
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
/**
|
|
223
229
|
* MDAST plugins that apply to both `.md` and `.mdx`. Currently just the
|
|
224
|
-
* base-path link rewrite, added only when a `basePath` is
|
|
230
|
+
* base-path link rewrite, added only when a `basePath` or `deployBase` is
|
|
231
|
+
* configured.
|
|
225
232
|
*/
|
|
226
233
|
const blumeSharedMdastPlugins = (
|
|
227
234
|
options: BlumeMarkdownOptions
|
|
228
235
|
): MdastPlugin[] =>
|
|
229
|
-
options.basePath
|
|
230
|
-
? [
|
|
236
|
+
options.basePath || options.deployBase
|
|
237
|
+
? [
|
|
238
|
+
baseLinksPlugin(
|
|
239
|
+
options.deployBase ?? "",
|
|
240
|
+
options.basePath ?? ""
|
|
241
|
+
) as unknown as MdastPlugin,
|
|
242
|
+
]
|
|
231
243
|
: [];
|
|
232
244
|
|
|
233
245
|
/** Sätteri processor for plain `.md`, with Blume's curated feature set. */
|
|
@@ -104,8 +104,13 @@ export const inlineCodeHighlightPlugin = (
|
|
|
104
104
|
type: "element",
|
|
105
105
|
};
|
|
106
106
|
} catch {
|
|
107
|
-
// Unknown language or load failure:
|
|
108
|
-
//
|
|
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
|
+
};
|
|
109
114
|
}
|
|
110
115
|
},
|
|
111
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") {
|
package/src/og/card.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { Renderer } from "@takumi-rs/core";
|
|
|
2
2
|
import { container, image, text } from "@takumi-rs/helpers";
|
|
3
3
|
import type { Node } from "@takumi-rs/helpers";
|
|
4
4
|
|
|
5
|
+
import { OG_IMAGE_HEIGHT, OG_IMAGE_WIDTH } from "./dimensions.ts";
|
|
6
|
+
|
|
5
7
|
// Reuse one renderer (and its loaded default fonts) across all images.
|
|
6
8
|
let renderer: Renderer | null = null;
|
|
7
9
|
const getRenderer = (): Renderer => {
|
|
@@ -53,8 +55,8 @@ export interface OgCardOptions {
|
|
|
53
55
|
site?: string;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
const WIDTH =
|
|
57
|
-
const HEIGHT =
|
|
58
|
+
const WIDTH = OG_IMAGE_WIDTH;
|
|
59
|
+
const HEIGHT = OG_IMAGE_HEIGHT;
|
|
58
60
|
|
|
59
61
|
// Light neutral scale mirrored from the docs homepage theme tokens:
|
|
60
62
|
// FOREGROUND = --foreground, MUTED = --muted-foreground, FAINT = that lighter,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dimensions of a generated OG card, shared by the renderer (`card.ts`) and the
|
|
3
|
+
* layouts that declare them as `og:image:width`/`og:image:height` so a crawler
|
|
4
|
+
* can lay out the card without fetching the PNG first.
|
|
5
|
+
*
|
|
6
|
+
* This lives apart from `card.ts` because that module imports the Takumi native
|
|
7
|
+
* binding at load; a layout importing it would drag the renderer into every
|
|
8
|
+
* page render (and into the prerender/SSR bundles that externalize it).
|
|
9
|
+
*/
|
|
10
|
+
export const OG_IMAGE_WIDTH = 1200;
|
|
11
|
+
export const OG_IMAGE_HEIGHT = 630;
|
|
12
|
+
export const OG_IMAGE_TYPE = "image/png";
|
package/src/openapi/model.ts
CHANGED
|
@@ -104,12 +104,13 @@ 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[] = [];
|
|
115
116
|
const tagsSeen = new Set<string>();
|
|
@@ -117,10 +118,17 @@ export const extractOperations = (
|
|
|
117
118
|
(document.tags ?? []).map((tag) => [tag.name, tag.description ?? ""])
|
|
118
119
|
);
|
|
119
120
|
const seen = new Set<string>();
|
|
121
|
+
const warnings: string[] = [];
|
|
120
122
|
|
|
121
123
|
for (const [path, rawItem] of Object.entries(document.paths ?? {})) {
|
|
122
124
|
const item = rawItem as PathItemObject | undefined;
|
|
123
|
-
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
|
+
);
|
|
124
132
|
continue;
|
|
125
133
|
}
|
|
126
134
|
for (const method of HTTP_METHODS) {
|
|
@@ -161,7 +169,7 @@ export const extractOperations = (
|
|
|
161
169
|
slug: slugify(name) || "operations",
|
|
162
170
|
}));
|
|
163
171
|
|
|
164
|
-
return { operations, tags };
|
|
172
|
+
return { operations, tags, warnings };
|
|
165
173
|
};
|
|
166
174
|
|
|
167
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
|
};
|
|
@@ -31,6 +31,12 @@ export interface ReferenceSource {
|
|
|
31
31
|
slug: string;
|
|
32
32
|
/** Normalized route the reference mounts at, e.g. `/reference`. */
|
|
33
33
|
route: string;
|
|
34
|
+
/**
|
|
35
|
+
* Site-wide `basePath` the rendered pages are mounted under (`""` when
|
|
36
|
+
* none). Kept separate from `route` — the content pipeline applies it to
|
|
37
|
+
* staged entries itself — so consumers prefix only the URLs they emit.
|
|
38
|
+
*/
|
|
39
|
+
basePath: string;
|
|
34
40
|
label: string;
|
|
35
41
|
/** Local path or `http(s)` URL, verbatim from config. */
|
|
36
42
|
spec: string;
|
|
@@ -38,6 +44,11 @@ export interface ReferenceSource {
|
|
|
38
44
|
theme?: string;
|
|
39
45
|
/** Display options carried through to the Blume renderer. */
|
|
40
46
|
display: ReferenceDisplay;
|
|
47
|
+
/**
|
|
48
|
+
* Warnings recorded while deduping — another source's route collided with
|
|
49
|
+
* this one and was dropped. Surfaced as diagnostics when the source loads.
|
|
50
|
+
*/
|
|
51
|
+
collisions?: string[];
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
@@ -78,7 +89,8 @@ const referencesFor = (
|
|
|
78
89
|
block: Block,
|
|
79
90
|
defaultLabel: string,
|
|
80
91
|
renderer: ReferenceRenderer,
|
|
81
|
-
display: ReferenceDisplay
|
|
92
|
+
display: ReferenceDisplay,
|
|
93
|
+
basePath: string
|
|
82
94
|
): ReferenceSource[] => {
|
|
83
95
|
if (!block.enabled) {
|
|
84
96
|
return [];
|
|
@@ -102,6 +114,7 @@ const referencesFor = (
|
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
return {
|
|
117
|
+
basePath,
|
|
105
118
|
display,
|
|
106
119
|
kind,
|
|
107
120
|
label,
|
|
@@ -131,9 +144,17 @@ export const resolveReferences = (
|
|
|
131
144
|
{
|
|
132
145
|
codeSamples: config.openapi.codeSamples,
|
|
133
146
|
expandSchemas: config.openapi.expandSchemas,
|
|
134
|
-
}
|
|
147
|
+
},
|
|
148
|
+
config.basePath
|
|
149
|
+
),
|
|
150
|
+
...referencesFor(
|
|
151
|
+
"asyncapi",
|
|
152
|
+
config.asyncapi,
|
|
153
|
+
"Events",
|
|
154
|
+
"scalar",
|
|
155
|
+
NO_DISPLAY,
|
|
156
|
+
config.basePath
|
|
135
157
|
),
|
|
136
|
-
...referencesFor("asyncapi", config.asyncapi, "Events", "scalar", NO_DISPLAY),
|
|
137
158
|
];
|
|
138
159
|
|
|
139
160
|
/** Nav tabs (header links) for every reference, regardless of renderer. */
|
|
@@ -152,19 +173,24 @@ export const referenceTabs = (config: ResolvedConfig): NavTab[] =>
|
|
|
152
173
|
/**
|
|
153
174
|
* Accept one resolved reference into the deduped Blume-rendered set, or return
|
|
154
175
|
* null to skip it. Mutates `seen`/`usedSlugs` so repeated routes/slugs collapse.
|
|
176
|
+
* A dropped route collision is recorded on the kept reference (mirroring the
|
|
177
|
+
* Scalar path's warning) — losing a whole spec's pages must not be silent.
|
|
155
178
|
*/
|
|
156
179
|
const blumeReferenceOf = (
|
|
157
180
|
ref: ReferenceSource,
|
|
158
|
-
seen:
|
|
181
|
+
seen: Map<string, ReferenceSource>,
|
|
159
182
|
usedSlugs: Set<string>
|
|
160
183
|
): ReferenceSource | null => {
|
|
161
184
|
if (ref.kind !== "openapi" || ref.renderer !== "blume") {
|
|
162
185
|
return null;
|
|
163
186
|
}
|
|
164
|
-
|
|
187
|
+
const kept = seen.get(ref.route);
|
|
188
|
+
if (kept) {
|
|
189
|
+
(kept.collisions ??= []).push(
|
|
190
|
+
`Two API reference sources resolve to ${ref.route}; keeping the first.`
|
|
191
|
+
);
|
|
165
192
|
return null;
|
|
166
193
|
}
|
|
167
|
-
seen.add(ref.route);
|
|
168
194
|
// Distinct routes can slugify identically (`/api/v1` and `/api-v1` both
|
|
169
195
|
// yield `api-v1`). The slug keys the `blume:openapi` data module, so a
|
|
170
196
|
// collision would let one spec silently overwrite the other while the
|
|
@@ -176,12 +202,16 @@ const blumeReferenceOf = (
|
|
|
176
202
|
n += 1;
|
|
177
203
|
}
|
|
178
204
|
usedSlugs.add(slug);
|
|
179
|
-
|
|
205
|
+
const accepted = slug === ref.slug ? ref : { ...ref, slug };
|
|
206
|
+
// Keep the accepted object (not the original) so a later collision's warning
|
|
207
|
+
// lands on the reference the caller actually receives.
|
|
208
|
+
seen.set(ref.route, accepted);
|
|
209
|
+
return accepted;
|
|
180
210
|
};
|
|
181
211
|
|
|
182
212
|
/** Blume-rendered OpenAPI references, deduped by route (first wins). */
|
|
183
213
|
export const blumeReferences = (config: ResolvedConfig): ReferenceSource[] => {
|
|
184
|
-
const seen = new
|
|
214
|
+
const seen = new Map<string, ReferenceSource>();
|
|
185
215
|
const usedSlugs = new Set<string>();
|
|
186
216
|
const result: ReferenceSource[] = [];
|
|
187
217
|
for (const ref of resolveReferences(config)) {
|