blume 0.7.0 → 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 +1487 -360
- package/dist/cli/index.js.map +91 -85
- 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 +36 -2
- package/dist/types/core/config.d.ts +3 -2
- package/dist/types/core/data.d.ts +2 -0
- package/dist/types/core/i18n-ui.d.ts +476 -132
- package/dist/types/core/schema.d.ts +216 -145
- 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 +8 -8
- 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 +27 -3
- package/docs/configuration/seo.mdx +2 -2
- package/docs/content/components.mdx +1 -1
- 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 +4 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/cli.mdx +8 -6
- package/package.json +14 -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/visibility.ts +74 -0
- package/src/astro/component-slots.ts +11 -1
- package/src/astro/generate.ts +76 -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 +99 -12
- package/src/blume-modules.d.ts +8 -0
- package/src/cli/commands/build.ts +99 -19
- 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/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 +56 -6
- package/src/components/islands/hooks.ts +28 -8
- package/src/components/layout/Banner.astro +10 -2
- package/src/components/layout/Header.astro +13 -4
- package/src/components/layout/Logo.astro +11 -3
- package/src/components/layout/NavTree.astro +17 -3
- package/src/components/layout/PageActions.astro +25 -10
- package/src/components/layout/PageLayout.astro +45 -8
- package/src/components/layout/ReferenceLayout.astro +8 -1
- package/src/components/layout/RootLayout.astro +67 -9
- package/src/components/layout/Search.astro +94 -22
- 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 +37 -2
- package/src/core/config.ts +3 -2
- package/src/core/data.ts +2 -0
- package/src/core/graph.ts +15 -5
- package/src/core/i18n-ui.ts +45 -0
- 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 +36 -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 +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 +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/openapi/model.ts +12 -4
- package/src/openapi/parse.ts +21 -0
- package/src/openapi/references.ts +38 -8
- 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 +13 -12
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { basename, resolve } from "pathe";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
detectPackageManager,
|
|
5
|
+
titleize,
|
|
6
|
+
validateContentDir,
|
|
7
|
+
} from "./scaffold.ts";
|
|
8
|
+
import type {
|
|
9
|
+
InitAnswers,
|
|
10
|
+
PackageManager,
|
|
11
|
+
SourceKind,
|
|
12
|
+
Template,
|
|
13
|
+
} from "./scaffold.ts";
|
|
14
|
+
|
|
15
|
+
/** One prompt option: value plus display label and optional hint. */
|
|
16
|
+
interface PromptOption<Value> {
|
|
17
|
+
hint?: string;
|
|
18
|
+
label: string;
|
|
19
|
+
value: Value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The prompt surface `collectAnswers` needs — structurally satisfied by the
|
|
24
|
+
* `@clack/prompts` module itself, and by plain fakes in tests. Every prompt
|
|
25
|
+
* resolves to a cancel symbol when the user aborts.
|
|
26
|
+
*/
|
|
27
|
+
export interface Prompter {
|
|
28
|
+
multiselect: (opts: {
|
|
29
|
+
initialValues?: SourceKind[];
|
|
30
|
+
message: string;
|
|
31
|
+
options: PromptOption<SourceKind>[];
|
|
32
|
+
required?: boolean;
|
|
33
|
+
}) => Promise<SourceKind[] | symbol>;
|
|
34
|
+
select: (opts: {
|
|
35
|
+
message: string;
|
|
36
|
+
options: PromptOption<Template>[];
|
|
37
|
+
}) => Promise<Template | symbol>;
|
|
38
|
+
text: (opts: {
|
|
39
|
+
defaultValue?: string;
|
|
40
|
+
initialValue?: string;
|
|
41
|
+
message: string;
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
validate?: (value: string | undefined) => string | undefined;
|
|
44
|
+
}) => Promise<string | symbol>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Flag values the user passed explicitly; each one skips its question. */
|
|
48
|
+
export interface InitFlags {
|
|
49
|
+
contentDir?: string;
|
|
50
|
+
directory?: string;
|
|
51
|
+
packageManager?: PackageManager;
|
|
52
|
+
template?: Template;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cancelled = (value: unknown): value is symbol =>
|
|
56
|
+
typeof value === "symbol";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run the interactive init flow, returning the collected answers or `null`
|
|
60
|
+
* when the user cancels. All questions run before anything is written, so a
|
|
61
|
+
* cancel never leaves a partial scaffold behind.
|
|
62
|
+
*/
|
|
63
|
+
export const collectAnswers = async (
|
|
64
|
+
prompter: Prompter,
|
|
65
|
+
flags: InitFlags,
|
|
66
|
+
defaults: { cwd: string; userAgent?: string }
|
|
67
|
+
): Promise<InitAnswers | null> => {
|
|
68
|
+
const directory =
|
|
69
|
+
flags.directory ??
|
|
70
|
+
(await prompter.text({
|
|
71
|
+
defaultValue: ".",
|
|
72
|
+
message: "Where should we create your project?",
|
|
73
|
+
placeholder: "./my-docs",
|
|
74
|
+
}));
|
|
75
|
+
if (cancelled(directory)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const root = resolve(defaults.cwd, directory);
|
|
80
|
+
const title = await prompter.text({
|
|
81
|
+
initialValue: titleize(basename(root)),
|
|
82
|
+
message: "What's your docs site called?",
|
|
83
|
+
validate: (value) =>
|
|
84
|
+
value?.trim() ? undefined : "Give your docs site a name.",
|
|
85
|
+
});
|
|
86
|
+
if (cancelled(title)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const template =
|
|
91
|
+
flags.template ??
|
|
92
|
+
(await prompter.select({
|
|
93
|
+
message: "Which template?",
|
|
94
|
+
options: [
|
|
95
|
+
{ hint: "Markdown docs site", label: "docs", value: "docs" },
|
|
96
|
+
{ hint: "OpenAPI reference at /api", label: "api", value: "api" },
|
|
97
|
+
{ hint: "SDK docs with an install page", label: "sdk", value: "sdk" },
|
|
98
|
+
{
|
|
99
|
+
hint: "Docs plus a changelog tab",
|
|
100
|
+
label: "changelog",
|
|
101
|
+
value: "changelog",
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
}));
|
|
105
|
+
if (cancelled(template)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const picked = await prompter.multiselect({
|
|
110
|
+
initialValues: ["filesystem"],
|
|
111
|
+
message: "Where does your content live?",
|
|
112
|
+
options: [
|
|
113
|
+
{ hint: "Local .mdx files", label: "filesystem", value: "filesystem" },
|
|
114
|
+
{
|
|
115
|
+
hint: "Changelog from GitHub Releases",
|
|
116
|
+
label: "github-releases",
|
|
117
|
+
value: "github-releases",
|
|
118
|
+
},
|
|
119
|
+
{ hint: "A Notion database", label: "notion", value: "notion" },
|
|
120
|
+
{ hint: "A Sanity dataset", label: "sanity", value: "sanity" },
|
|
121
|
+
{
|
|
122
|
+
hint: "MDX fetched from a GitHub repo",
|
|
123
|
+
label: "mdx-remote",
|
|
124
|
+
value: "mdx-remote",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
required: false,
|
|
128
|
+
});
|
|
129
|
+
if (cancelled(picked)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
// No selection desugars to the implicit local source, same as the schema.
|
|
133
|
+
const sources: SourceKind[] = picked.length === 0 ? ["filesystem"] : picked;
|
|
134
|
+
|
|
135
|
+
let contentDir = flags.contentDir ?? "docs";
|
|
136
|
+
if (flags.contentDir === undefined && sources.includes("filesystem")) {
|
|
137
|
+
const answer = await prompter.text({
|
|
138
|
+
defaultValue: "docs",
|
|
139
|
+
message: "Content directory?",
|
|
140
|
+
placeholder: "docs",
|
|
141
|
+
validate: (value) => validateContentDir(root, value || "docs"),
|
|
142
|
+
});
|
|
143
|
+
if (cancelled(answer)) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
contentDir = answer;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
contentDir,
|
|
151
|
+
directory,
|
|
152
|
+
packageManager:
|
|
153
|
+
flags.packageManager ?? detectPackageManager(defaults.userAgent),
|
|
154
|
+
sources,
|
|
155
|
+
template,
|
|
156
|
+
title,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative } from "pathe";
|
|
5
|
+
|
|
6
|
+
import { blumePackageJson, toPackageName } from "../../core/package-json.ts";
|
|
7
|
+
|
|
8
|
+
export const TEMPLATES = ["docs", "api", "sdk", "changelog"] as const;
|
|
9
|
+
export type Template = (typeof TEMPLATES)[number];
|
|
10
|
+
|
|
11
|
+
export const PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
|
|
12
|
+
export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
|
|
13
|
+
|
|
14
|
+
/** The content-source kinds `init` can scaffold a config block for. */
|
|
15
|
+
export const SOURCE_KINDS = [
|
|
16
|
+
"filesystem",
|
|
17
|
+
"github-releases",
|
|
18
|
+
"notion",
|
|
19
|
+
"sanity",
|
|
20
|
+
"mdx-remote",
|
|
21
|
+
] as const;
|
|
22
|
+
export type SourceKind = (typeof SOURCE_KINDS)[number];
|
|
23
|
+
|
|
24
|
+
/** Everything the scaffolder needs, whether prompted or derived from flags. */
|
|
25
|
+
export interface InitAnswers {
|
|
26
|
+
contentDir: string;
|
|
27
|
+
/** Target directory as the user gave it (`.` = current directory). */
|
|
28
|
+
directory: string;
|
|
29
|
+
packageManager: PackageManager;
|
|
30
|
+
sources: SourceKind[];
|
|
31
|
+
template: Template;
|
|
32
|
+
title: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One file the scaffold plan will write; `path` is absolute. */
|
|
36
|
+
export interface ScaffoldFile {
|
|
37
|
+
content: string;
|
|
38
|
+
path: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Log sink for scaffold output — satisfied by both consola and clack's `log`. */
|
|
42
|
+
export interface ScaffoldLog {
|
|
43
|
+
info: (message: string) => void;
|
|
44
|
+
success: (message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A starter: a config fragment plus seed content files (paths relative to root). */
|
|
48
|
+
interface Starter {
|
|
49
|
+
configExtra: string;
|
|
50
|
+
files: (contentDir: string) => { content: string; path: string }[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const page = (title: string, description: string, body: string): string =>
|
|
54
|
+
`---\ntitle: ${title}\ndescription: ${description}\n---\n\n${body}\n`;
|
|
55
|
+
|
|
56
|
+
export const STARTERS: Record<Template, Starter> = {
|
|
57
|
+
api: {
|
|
58
|
+
configExtra: `
|
|
59
|
+
openapi: {
|
|
60
|
+
enabled: true,
|
|
61
|
+
route: "/api",
|
|
62
|
+
sources: [
|
|
63
|
+
{
|
|
64
|
+
label: "Petstore",
|
|
65
|
+
spec: "https://petstore3.swagger.io/api/v3/openapi.json",
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},`,
|
|
69
|
+
files: (dir) => [
|
|
70
|
+
{
|
|
71
|
+
content: page(
|
|
72
|
+
"API Reference",
|
|
73
|
+
"Explore the API.",
|
|
74
|
+
"# API Reference\n\nYour OpenAPI spec renders at [`/api`](/api). Point `openapi.sources` at your own spec in `blume.config.ts`."
|
|
75
|
+
),
|
|
76
|
+
path: join(dir, "index.mdx"),
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
changelog: {
|
|
81
|
+
configExtra: `
|
|
82
|
+
navigation: {
|
|
83
|
+
tabs: [
|
|
84
|
+
{ label: "Docs", path: "/" },
|
|
85
|
+
{ label: "Changelog", path: "/changelog" },
|
|
86
|
+
],
|
|
87
|
+
},`,
|
|
88
|
+
files: (dir) => [
|
|
89
|
+
{
|
|
90
|
+
content: page(
|
|
91
|
+
"Introduction",
|
|
92
|
+
"Welcome to your new Blume docs.",
|
|
93
|
+
"# Introduction\n\nWrite your docs here, and log releases under `changelog/`."
|
|
94
|
+
),
|
|
95
|
+
path: join(dir, "index.mdx"),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
content: `---\ntitle: v1.0.0\ntype: changelog\ndate: 2026-01-01\n---\n\nThe first release. Edit \`${dir}/changelog/v1-0-0.mdx\` or add new entries beside it.\n`,
|
|
99
|
+
path: join(dir, "changelog", "v1-0-0.mdx"),
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
docs: {
|
|
104
|
+
configExtra: "",
|
|
105
|
+
files: (dir) => [
|
|
106
|
+
{
|
|
107
|
+
content: page(
|
|
108
|
+
"Introduction",
|
|
109
|
+
"Welcome to your new Blume docs.",
|
|
110
|
+
`# Introduction\n\nWelcome to **Blume** — markdown-first docs powered by Astro and Vite.\n\nEdit \`${dir}/index.mdx\` to get started, then run \`blume dev\`.`
|
|
111
|
+
),
|
|
112
|
+
path: join(dir, "index.mdx"),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
sdk: {
|
|
117
|
+
configExtra: "",
|
|
118
|
+
files: (dir) => [
|
|
119
|
+
{
|
|
120
|
+
content: page(
|
|
121
|
+
"Introduction",
|
|
122
|
+
"Get started with the SDK.",
|
|
123
|
+
"# Introduction\n\nInstall the SDK and make your first call. See [Installation](/installation)."
|
|
124
|
+
),
|
|
125
|
+
path: join(dir, "index.mdx"),
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
content: page(
|
|
129
|
+
"Installation",
|
|
130
|
+
"Install the SDK.",
|
|
131
|
+
"# Installation\n\n```package-install\nyour-sdk\n```"
|
|
132
|
+
),
|
|
133
|
+
path: join(dir, "installation.mdx"),
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Install + dev commands to print for the chosen package manager, plus the
|
|
141
|
+
* prefix that runs a locally installed bin (`exec`, e.g. `npx blume eject`) —
|
|
142
|
+
* dependency bins aren't on PATH, so a bare `blume …` hint would not run.
|
|
143
|
+
*/
|
|
144
|
+
export const commandsFor = (
|
|
145
|
+
pm: PackageManager
|
|
146
|
+
): { build: string; dev: string; exec: string; install: string } => ({
|
|
147
|
+
// `bun build` invokes Bun's bundler, not the package.json `build` script —
|
|
148
|
+
// unlike `bun dev`, the script name is shadowed by a builtin subcommand.
|
|
149
|
+
build: pm === "npm" || pm === "bun" ? `${pm} run build` : `${pm} build`,
|
|
150
|
+
dev: pm === "npm" ? "npm run dev" : `${pm} dev`,
|
|
151
|
+
exec: { bun: "bunx", npm: "npx", pnpm: "pnpm exec", yarn: "yarn" }[pm],
|
|
152
|
+
install: `${pm} install`,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Derive the package manager from an npm user-agent string (the first
|
|
157
|
+
* `name/version` token of `npm_config_user_agent`), falling back to npm.
|
|
158
|
+
*/
|
|
159
|
+
export const detectPackageManager = (userAgent?: string): PackageManager => {
|
|
160
|
+
const name = userAgent?.split("/")[0] as PackageManager | undefined;
|
|
161
|
+
return name !== undefined && PACKAGE_MANAGERS.includes(name) ? name : "npm";
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The content dir is joined into every scaffolded file path, so an absolute or
|
|
166
|
+
* `../`-escaping value would write outside the project. Returns an error
|
|
167
|
+
* message, or `undefined` when the dir is safe.
|
|
168
|
+
*/
|
|
169
|
+
export const validateContentDir = (
|
|
170
|
+
root: string,
|
|
171
|
+
dir: string
|
|
172
|
+
): string | undefined =>
|
|
173
|
+
isAbsolute(dir) || relative(root, join(root, dir)).startsWith("..")
|
|
174
|
+
? "Must be a relative path inside the project."
|
|
175
|
+
: undefined;
|
|
176
|
+
|
|
177
|
+
/** Turn a directory name into a display title: `my-docs` → `My Docs`. */
|
|
178
|
+
export const titleize = (raw: string): string => {
|
|
179
|
+
const words = raw
|
|
180
|
+
.replaceAll(/[-_.]+/gu, " ")
|
|
181
|
+
.split(/\s+/u)
|
|
182
|
+
.filter(Boolean);
|
|
183
|
+
return words.length === 0
|
|
184
|
+
? "My Docs"
|
|
185
|
+
: words
|
|
186
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
187
|
+
.join(" ");
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** True when any selected source is remote (everything except `filesystem`). */
|
|
191
|
+
const hasRemoteSource = (sources: SourceKind[]): boolean =>
|
|
192
|
+
sources.some((source) => source !== "filesystem");
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Config snippets for each remote source kind, with placeholder values to
|
|
196
|
+
* replace and comments naming the env var each source authenticates with.
|
|
197
|
+
*/
|
|
198
|
+
const sourceSnippetFor = (kind: Exclude<SourceKind, "filesystem">): string => {
|
|
199
|
+
switch (kind) {
|
|
200
|
+
case "github-releases": {
|
|
201
|
+
return ` // Changelog entries from GitHub Releases. Private repos read
|
|
202
|
+
// GITHUB_TOKEN from the environment.
|
|
203
|
+
{
|
|
204
|
+
type: "github-releases",
|
|
205
|
+
owner: "your-org",
|
|
206
|
+
repo: "your-repo",
|
|
207
|
+
prefix: "changelog",
|
|
208
|
+
},`;
|
|
209
|
+
}
|
|
210
|
+
case "notion": {
|
|
211
|
+
return ` // Pages from a Notion database. Reads NOTION_TOKEN from the environment.
|
|
212
|
+
{
|
|
213
|
+
type: "notion",
|
|
214
|
+
database: "your-database-id",
|
|
215
|
+
prefix: "notion",
|
|
216
|
+
},`;
|
|
217
|
+
}
|
|
218
|
+
case "sanity": {
|
|
219
|
+
return ` // Documents from a Sanity dataset. Private datasets read SANITY_TOKEN
|
|
220
|
+
// from the environment.
|
|
221
|
+
{
|
|
222
|
+
type: "sanity",
|
|
223
|
+
projectId: "your-project-id",
|
|
224
|
+
dataset: "production",
|
|
225
|
+
query: \`*[_type == "doc"]\`,
|
|
226
|
+
prefix: "sanity",
|
|
227
|
+
},`;
|
|
228
|
+
}
|
|
229
|
+
case "mdx-remote": {
|
|
230
|
+
return ` // MDX fetched from a GitHub repo. Private repos read GITHUB_TOKEN
|
|
231
|
+
// from the environment.
|
|
232
|
+
{
|
|
233
|
+
type: "mdx-remote",
|
|
234
|
+
github: { owner: "your-org", repo: "your-repo", path: "docs" },
|
|
235
|
+
prefix: "remote",
|
|
236
|
+
},`;
|
|
237
|
+
}
|
|
238
|
+
default: {
|
|
239
|
+
return kind satisfies never;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The `content` block for the generated config, or an empty string when the
|
|
246
|
+
* defaults (filesystem source, `docs` root) apply — keeping the default
|
|
247
|
+
* scaffold byte-identical to a config with no `content` key at all.
|
|
248
|
+
*/
|
|
249
|
+
const contentBlockFor = (answers: InitAnswers): string => {
|
|
250
|
+
const sources =
|
|
251
|
+
answers.sources.length === 0 ? ["filesystem" as const] : answers.sources;
|
|
252
|
+
if (!hasRemoteSource(sources)) {
|
|
253
|
+
return answers.contentDir === "docs"
|
|
254
|
+
? ""
|
|
255
|
+
: `
|
|
256
|
+
content: {
|
|
257
|
+
root: ${JSON.stringify(answers.contentDir)},
|
|
258
|
+
},`;
|
|
259
|
+
}
|
|
260
|
+
// Explicit sources replace the implicit filesystem desugar, so the local
|
|
261
|
+
// content dir must be listed alongside the remote sources to stay included.
|
|
262
|
+
const entries = SOURCE_KINDS.filter((kind) => sources.includes(kind)).map(
|
|
263
|
+
(kind) =>
|
|
264
|
+
kind === "filesystem"
|
|
265
|
+
? ` { type: "filesystem", root: ${JSON.stringify(answers.contentDir)} },`
|
|
266
|
+
: sourceSnippetFor(kind)
|
|
267
|
+
);
|
|
268
|
+
return `
|
|
269
|
+
content: {
|
|
270
|
+
sources: [
|
|
271
|
+
${entries.join("\n")}
|
|
272
|
+
],
|
|
273
|
+
},`;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** The full `blume.config.ts` text for the chosen answers. */
|
|
277
|
+
export const buildConfig = (
|
|
278
|
+
answers: InitAnswers
|
|
279
|
+
): string => `import { defineConfig } from "blume";
|
|
280
|
+
|
|
281
|
+
export default defineConfig({
|
|
282
|
+
title: ${JSON.stringify(answers.title)},
|
|
283
|
+
description: "Documentation powered by Blume.",${STARTERS[answers.template].configExtra}${contentBlockFor(answers)}
|
|
284
|
+
});
|
|
285
|
+
`;
|
|
286
|
+
|
|
287
|
+
/** SDK dependencies required by the selected remote sources. */
|
|
288
|
+
const extraDepsFor = (sources: SourceKind[]): Record<string, string> => ({
|
|
289
|
+
...(sources.includes("notion") && { "@notionhq/client": "^2.2.15" }),
|
|
290
|
+
...(sources.includes("sanity") && { "@sanity/client": "^6.21.0" }),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
/** Every file `init` should write for the given answers, package.json first. */
|
|
294
|
+
export const buildPlan = (
|
|
295
|
+
root: string,
|
|
296
|
+
answers: InitAnswers
|
|
297
|
+
): ScaffoldFile[] => {
|
|
298
|
+
const files: ScaffoldFile[] = [
|
|
299
|
+
{
|
|
300
|
+
content: blumePackageJson(
|
|
301
|
+
toPackageName(basename(root)),
|
|
302
|
+
extraDepsFor(answers.sources)
|
|
303
|
+
),
|
|
304
|
+
path: join(root, "package.json"),
|
|
305
|
+
},
|
|
306
|
+
{ content: buildConfig(answers), path: join(root, "blume.config.ts") },
|
|
307
|
+
];
|
|
308
|
+
// Seed pages only make sense when a local filesystem source will read them.
|
|
309
|
+
if (answers.sources.length === 0 || answers.sources.includes("filesystem")) {
|
|
310
|
+
files.push(
|
|
311
|
+
...STARTERS[answers.template]
|
|
312
|
+
.files(answers.contentDir)
|
|
313
|
+
.map((file) => ({ ...file, path: join(root, file.path) }))
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return files;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
const writeFileSafe = async (
|
|
320
|
+
file: ScaffoldFile,
|
|
321
|
+
log: ScaffoldLog
|
|
322
|
+
): Promise<boolean> => {
|
|
323
|
+
if (existsSync(file.path)) {
|
|
324
|
+
log.info(`Skipped existing ${file.path}`);
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
328
|
+
await writeFile(file.path, file.content, "utf-8");
|
|
329
|
+
log.success(`Created ${file.path}`);
|
|
330
|
+
return true;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Write the plan's files, skipping any that already exist. Reports whether a
|
|
335
|
+
* `package.json` was newly created (it decides the install next-step).
|
|
336
|
+
*/
|
|
337
|
+
export const applyPlan = async (
|
|
338
|
+
files: ScaffoldFile[],
|
|
339
|
+
log: ScaffoldLog
|
|
340
|
+
): Promise<{ createdPackage: boolean }> => {
|
|
341
|
+
const created = await Promise.all(
|
|
342
|
+
files.map((file) => writeFileSafe(file, log))
|
|
343
|
+
);
|
|
344
|
+
const createdPackage = files.some(
|
|
345
|
+
(file, index) => created[index] && basename(file.path) === "package.json"
|
|
346
|
+
);
|
|
347
|
+
return { createdPackage };
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/** Env vars the selected sources read, in a stable order. */
|
|
351
|
+
const envVarsFor = (sources: SourceKind[]): string[] =>
|
|
352
|
+
[
|
|
353
|
+
["GITHUB_TOKEN", ["github-releases", "mdx-remote"]] as const,
|
|
354
|
+
["NOTION_TOKEN", ["notion"]] as const,
|
|
355
|
+
["SANITY_TOKEN", ["sanity"]] as const,
|
|
356
|
+
]
|
|
357
|
+
.filter(([, kinds]) => kinds.some((kind) => sources.includes(kind)))
|
|
358
|
+
.map(([envVar]) => envVar);
|
|
359
|
+
|
|
360
|
+
/** The next-steps message: `cd` hint, install/dev commands, and token setup. */
|
|
361
|
+
export const nextSteps = (
|
|
362
|
+
answers: InitAnswers,
|
|
363
|
+
createdPackage: boolean
|
|
364
|
+
): string => {
|
|
365
|
+
const commands = commandsFor(answers.packageManager);
|
|
366
|
+
const lines: string[] = [];
|
|
367
|
+
if (answers.directory !== ".") {
|
|
368
|
+
lines.push(`cd ${answers.directory}`);
|
|
369
|
+
}
|
|
370
|
+
if (createdPackage) {
|
|
371
|
+
lines.push(commands.install);
|
|
372
|
+
}
|
|
373
|
+
lines.push(commands.dev);
|
|
374
|
+
const envVars = envVarsFor(answers.sources);
|
|
375
|
+
const auth =
|
|
376
|
+
envVars.length > 0
|
|
377
|
+
? `\nSet ${envVars.join(" and ")} in .env.local so your sources can authenticate.\n`
|
|
378
|
+
: "";
|
|
379
|
+
return `Next steps:\n\n ${lines.join("\n ")}\n${auth}`;
|
|
380
|
+
};
|
|
@@ -67,6 +67,22 @@ const accordionId = id ?? slugify(title);
|
|
|
67
67
|
if (!state.__blumeAccordionHash) {
|
|
68
68
|
state.__blumeAccordionHash = true;
|
|
69
69
|
|
|
70
|
+
// Repeated titles slug to the same id (e.g. several <Expandable> blocks all
|
|
71
|
+
// defaulting to "show-more"); suffix the later ones so ids stay unique. The
|
|
72
|
+
// first keeps the plain slug, so hash deep-links stay stable.
|
|
73
|
+
const dedupeAccordionIds = () => {
|
|
74
|
+
const seen = new Map<string, number>();
|
|
75
|
+
for (const accordion of document.querySelectorAll<HTMLElement>(
|
|
76
|
+
"[data-blume-accordion][id]"
|
|
77
|
+
)) {
|
|
78
|
+
const count = seen.get(accordion.id) ?? 0;
|
|
79
|
+
seen.set(accordion.id, count + 1);
|
|
80
|
+
if (count > 0) {
|
|
81
|
+
accordion.id = `${accordion.id}-${count + 1}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
70
86
|
const accordionFromHash = () => {
|
|
71
87
|
const id = decodeURIComponent(window.location.hash.slice(1));
|
|
72
88
|
if (!id) {
|
|
@@ -106,13 +122,16 @@ const accordionId = id ?? slugify(title);
|
|
|
106
122
|
true
|
|
107
123
|
);
|
|
108
124
|
|
|
125
|
+
const init = () => {
|
|
126
|
+
dedupeAccordionIds();
|
|
127
|
+
openHashAccordion();
|
|
128
|
+
};
|
|
129
|
+
|
|
109
130
|
window.addEventListener("hashchange", openHashAccordion);
|
|
110
131
|
if (document.readyState === "loading") {
|
|
111
|
-
document.addEventListener("DOMContentLoaded",
|
|
112
|
-
once: true,
|
|
113
|
-
});
|
|
132
|
+
document.addEventListener("DOMContentLoaded", init, { once: true });
|
|
114
133
|
} else {
|
|
115
|
-
|
|
134
|
+
init();
|
|
116
135
|
}
|
|
117
136
|
}
|
|
118
137
|
</script>
|
|
@@ -97,10 +97,12 @@ const resolvedClass = astroClass ?? className;
|
|
|
97
97
|
const colorClass = activeStroke
|
|
98
98
|
? strokeColorClass[colorName] ?? strokeColorClass.gray
|
|
99
99
|
: filledColorClass[colorName] ?? filledColorClass.gray;
|
|
100
|
+
// `color-mix` instead of appending a hex alpha byte, which breaks shorthand
|
|
101
|
+
// hex (`#f00` + `20` is not a color); 12% matches the old `20` (32/255) alpha.
|
|
100
102
|
const customStyle = customColor
|
|
101
103
|
? activeStroke
|
|
102
104
|
? `border-color:${customColor};color:${customColor}`
|
|
103
|
-
: `background
|
|
105
|
+
: `background:color-mix(in srgb, ${customColor} 12%, transparent);color:${customColor}`
|
|
104
106
|
: undefined;
|
|
105
107
|
---
|
|
106
108
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Icon from "../Icon.astro";
|
|
3
|
+
import { withBase } from "../islands/base-path.ts";
|
|
4
|
+
import { contentHref } from "./base-href.ts";
|
|
3
5
|
|
|
4
6
|
interface Props {
|
|
5
7
|
title?: string;
|
|
@@ -54,7 +56,7 @@ const variantClass = {
|
|
|
54
56
|
isHorizontal ? "sm:flex" : "",
|
|
55
57
|
]}
|
|
56
58
|
data-blume-card
|
|
57
|
-
href={href}
|
|
59
|
+
href={href === undefined ? undefined : contentHref(href)}
|
|
58
60
|
rel={external ? "noreferrer" : undefined}
|
|
59
61
|
target={external ? "_blank" : undefined}
|
|
60
62
|
>
|
|
@@ -66,7 +68,7 @@ const variantClass = {
|
|
|
66
68
|
"w-full object-cover",
|
|
67
69
|
isHorizontal ? "sm:w-40 sm:shrink-0" : "max-h-48",
|
|
68
70
|
]}
|
|
69
|
-
src={img}
|
|
71
|
+
src={withBase(img)}
|
|
70
72
|
/>
|
|
71
73
|
)
|
|
72
74
|
}
|
|
@@ -26,7 +26,16 @@ const canRenderIcon =
|
|
|
26
26
|
<span class:list={[markerClass, !canRenderIcon && "before:[content:counter(blume-step)]"]}>
|
|
27
27
|
{canRenderIcon && <Icon name={icon} size={14} />}
|
|
28
28
|
</span>
|
|
29
|
-
{
|
|
29
|
+
{
|
|
30
|
+
// Consumes the `titleSize` variables set by <Steps>; the fallbacks match
|
|
31
|
+
// its "p" default (and the prose-inherited sizing) so a bare <Step>
|
|
32
|
+
// renders identically.
|
|
33
|
+
title && (
|
|
34
|
+
<p class="mb-1.5 font-semibold text-[length:var(--blume-step-title-size,1rem)] leading-[var(--blume-step-title-line-height,1.75rem)]">
|
|
35
|
+
{title}
|
|
36
|
+
</p>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
30
39
|
<div class="[&>:first-child]:mt-0! [&>:last-child]:mb-0!">
|
|
31
40
|
<slot />
|
|
32
41
|
</div>
|
|
@@ -51,6 +51,10 @@ const hashEnabled = String(hash) !== "false";
|
|
|
51
51
|
"w-full rounded-blume border border-border bg-background px-3 py-2 font-medium text-foreground text-sm";
|
|
52
52
|
const SYNC_EVENT = "blume-tabs-change";
|
|
53
53
|
|
|
54
|
+
// Disambiguates generated trigger/panel ids when the same tab labels appear
|
|
55
|
+
// in several <Tabs> instances on one page.
|
|
56
|
+
let instanceCount = 0;
|
|
57
|
+
|
|
54
58
|
const panelTitle = (panel: HTMLElement, index: number) =>
|
|
55
59
|
panel.dataset.title ?? `Tab ${index + 1}`;
|
|
56
60
|
const panelId = (panel: HTMLElement) => panel.dataset.tabId ?? panel.id;
|
|
@@ -119,7 +123,8 @@ const hashEnabled = String(hash) !== "false";
|
|
|
119
123
|
if (this.dataset.dropdown === "true") {
|
|
120
124
|
this.#select = document.createElement("select");
|
|
121
125
|
this.#select.className = SELECT_CLASS;
|
|
122
|
-
|
|
126
|
+
// Neutral default: tab sets aren't necessarily code examples.
|
|
127
|
+
this.#select.setAttribute("aria-label", "Select tab");
|
|
123
128
|
panels.forEach((panel, index) => {
|
|
124
129
|
const option = document.createElement("option");
|
|
125
130
|
option.value = String(index);
|
|
@@ -131,6 +136,7 @@ const hashEnabled = String(hash) !== "false";
|
|
|
131
136
|
});
|
|
132
137
|
list.appendChild(this.#select);
|
|
133
138
|
} else {
|
|
139
|
+
const instance = ++instanceCount;
|
|
134
140
|
panels.forEach((panel, index) => {
|
|
135
141
|
const trigger = document.createElement("button");
|
|
136
142
|
trigger.type = "button";
|
|
@@ -138,9 +144,15 @@ const hashEnabled = String(hash) !== "false";
|
|
|
138
144
|
trigger.className = TRIGGER_CLASS;
|
|
139
145
|
appendTriggerContent(trigger, panel, index);
|
|
140
146
|
trigger.tabIndex = index === 0 ? 0 : -1;
|
|
141
|
-
|
|
142
|
-
|
|
147
|
+
// Wire tab and panel both ways. An author-supplied panel id is kept;
|
|
148
|
+
// otherwise derive unique ids from the label slug per instance.
|
|
149
|
+
const slug = panelId(panel) || `tab-${index + 1}`;
|
|
150
|
+
if (!panel.id) {
|
|
151
|
+
panel.id = `blume-tabpanel-${slug}-${instance}`;
|
|
143
152
|
}
|
|
153
|
+
trigger.id = `blume-tab-${slug}-${instance}`;
|
|
154
|
+
trigger.setAttribute("aria-controls", panel.id);
|
|
155
|
+
panel.setAttribute("aria-labelledby", trigger.id);
|
|
144
156
|
trigger.setAttribute(
|
|
145
157
|
"aria-selected",
|
|
146
158
|
index === 0 ? "true" : "false"
|