blume 0.1.4 → 0.2.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/dist/cli/index.js +359 -152
- package/dist/cli/index.js.map +11 -9
- package/dist/types/core/schema.d.ts +110 -6
- package/docs/advanced/changelog.mdx +28 -1
- package/docs/advanced/custom-pages.mdx +0 -1
- package/docs/configuration/index.mdx +12 -11
- package/docs/configuration/search.mdx +13 -1
- package/docs/configuration/theming.mdx +51 -0
- package/docs/content/components.mdx +18 -0
- package/docs/content/navigation.mdx +8 -2
- package/docs/content/sources.mdx +43 -0
- package/docs/content/syntax.mdx +1 -1
- package/docs/index.mdx +1 -1
- package/docs/reference/cli.mdx +7 -1
- package/docs/reference/frontmatter.mdx +9 -1
- package/package.json +1 -1
- package/src/astro/generate.ts +30 -13
- package/src/astro/templates.ts +10 -1
- package/src/cli/env.ts +84 -0
- package/src/cli/index.ts +5 -0
- package/src/cli/prepare.ts +5 -0
- package/src/components/content/CodeBlock.astro +7 -2
- package/src/components/layout/nav-utils.ts +50 -6
- package/src/core/schema.ts +26 -0
- package/src/core/sources/github-releases.ts +200 -0
- package/src/core/sources/resolve.ts +16 -0
- package/src/markdown/index.ts +24 -0
- package/docs/changelog/v0-1-0.mdx +0 -12
- package/docs/changelog/v0-2-0.mdx +0 -16
package/src/cli/env.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { dirname, join, resolve } from "pathe";
|
|
4
|
+
|
|
5
|
+
// Blume's remote sources (GitHub Releases, mdx-remote, Sanity, Notion…) read
|
|
6
|
+
// their tokens from `process.env` during the content scan — which runs before
|
|
7
|
+
// Astro/Vite boots, so Vite's own `.env` loading is too late. This loader fills
|
|
8
|
+
// that gap: it cascades `.env`/`.env.local` from the working dir up to the repo
|
|
9
|
+
// root, so a monorepo can keep one `.env` at the root and every app picks it up.
|
|
10
|
+
|
|
11
|
+
const ENV_LINE =
|
|
12
|
+
/^\s*(?:export\s+)?(?<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?<value>.*?)\s*$/u;
|
|
13
|
+
const DOUBLE_QUOTED = /^"(?<body>[\s\S]*)"$/u;
|
|
14
|
+
const SINGLE_QUOTED = /^'(?<body>[\s\S]*)'$/u;
|
|
15
|
+
|
|
16
|
+
/** Unquote a value, expanding `\n`/`\t`/escapes inside double quotes only. */
|
|
17
|
+
const unquote = (raw: string): string => {
|
|
18
|
+
const double = raw.match(DOUBLE_QUOTED)?.groups?.body;
|
|
19
|
+
if (double !== undefined) {
|
|
20
|
+
return double
|
|
21
|
+
.replaceAll("\\n", "\n")
|
|
22
|
+
.replaceAll("\\t", "\t")
|
|
23
|
+
.replaceAll('\\"', '"')
|
|
24
|
+
.replaceAll("\\\\", "\\");
|
|
25
|
+
}
|
|
26
|
+
const single = raw.match(SINGLE_QUOTED)?.groups?.body;
|
|
27
|
+
if (single !== undefined) {
|
|
28
|
+
return single;
|
|
29
|
+
}
|
|
30
|
+
return raw;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Parse `.env` text into key/value pairs, skipping blanks and `#` comments. */
|
|
34
|
+
export const parseEnv = (content: string): Record<string, string> => {
|
|
35
|
+
const env: Record<string, string> = {};
|
|
36
|
+
for (const line of content.split(/\r?\n/u)) {
|
|
37
|
+
if (line.trim() === "" || line.trimStart().startsWith("#")) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const groups = line.match(ENV_LINE)?.groups;
|
|
41
|
+
if (groups?.key !== undefined && groups.value !== undefined) {
|
|
42
|
+
env[groups.key] = unquote(groups.value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return env;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Apply parsed vars without clobbering anything already in `process.env`. */
|
|
49
|
+
const applyEnv = (parsed: Record<string, string>): void => {
|
|
50
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
51
|
+
if (!(key in process.env)) {
|
|
52
|
+
process.env[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const loadFile = (path: string): void => {
|
|
58
|
+
try {
|
|
59
|
+
if (existsSync(path)) {
|
|
60
|
+
applyEnv(parseEnv(readFileSync(path, "utf-8")));
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Env files are best-effort; a read/parse failure must not abort a build.
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Load `.env`/`.env.local`, cascading from `startDir` up to the repository root
|
|
69
|
+
* (the first ancestor containing a `.git`) or the filesystem root. Nearer files
|
|
70
|
+
* and existing `process.env` values win, so shell/CI overrides are never lost
|
|
71
|
+
* and `.env.local` layers over `.env`.
|
|
72
|
+
*/
|
|
73
|
+
export const loadEnvFiles = (startDir: string): void => {
|
|
74
|
+
let dir = resolve(startDir);
|
|
75
|
+
let done = false;
|
|
76
|
+
while (!done) {
|
|
77
|
+
loadFile(join(dir, ".env.local"));
|
|
78
|
+
loadFile(join(dir, ".env"));
|
|
79
|
+
const parent = dirname(dir);
|
|
80
|
+
// Stop at the repo root (nearest `.git`) or the filesystem root.
|
|
81
|
+
done = existsSync(join(dir, ".git")) || parent === dir;
|
|
82
|
+
dir = parent;
|
|
83
|
+
}
|
|
84
|
+
};
|
package/src/cli/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { migrateCommand } from "./commands/migrate.ts";
|
|
|
11
11
|
import { previewCommand } from "./commands/preview.ts";
|
|
12
12
|
import { syncCommand } from "./commands/sync.ts";
|
|
13
13
|
import { validateCommand } from "./commands/validate.ts";
|
|
14
|
+
import { loadEnvFiles } from "./env.ts";
|
|
14
15
|
|
|
15
16
|
const main = defineCommand({
|
|
16
17
|
meta: {
|
|
@@ -32,4 +33,8 @@ const main = defineCommand({
|
|
|
32
33
|
},
|
|
33
34
|
});
|
|
34
35
|
|
|
36
|
+
// Load `.env`/`.env.local` before any command runs so remote content sources
|
|
37
|
+
// can read their tokens (e.g. `GITHUB_TOKEN`) during the content scan.
|
|
38
|
+
loadEnvFiles(process.cwd());
|
|
39
|
+
|
|
35
40
|
runMain(main);
|
package/src/cli/prepare.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { BlumeError, hasErrors } from "../core/diagnostics.ts";
|
|
|
3
3
|
import { scanProject } from "../core/project-graph.ts";
|
|
4
4
|
import type { BlumeProject, BuildMode } from "../core/project-graph.ts";
|
|
5
5
|
import { serverFeatures } from "../core/server-features.ts";
|
|
6
|
+
import { loadEnvFiles } from "./env.ts";
|
|
6
7
|
import { logger, reportDiagnostics } from "./log.ts";
|
|
7
8
|
|
|
8
9
|
export interface PrepareOptions {
|
|
@@ -24,6 +25,10 @@ export interface PrepareOptions {
|
|
|
24
25
|
export const prepareProject = async (
|
|
25
26
|
options: PrepareOptions
|
|
26
27
|
): Promise<BlumeProject> => {
|
|
28
|
+
// Honor a `--root` that differs from cwd: remote sources read env during the
|
|
29
|
+
// scan below, and `loadEnvFiles` is a no-op for vars already set (cwd load).
|
|
30
|
+
loadEnvFiles(options.root);
|
|
31
|
+
|
|
27
32
|
let project: BlumeProject;
|
|
28
33
|
try {
|
|
29
34
|
project = await scanProject(options.root, {
|
|
@@ -19,10 +19,15 @@ interface Props {
|
|
|
19
19
|
lang?: string;
|
|
20
20
|
/** Show the brand language icon in the header (default on, like fences). */
|
|
21
21
|
icons?: boolean;
|
|
22
|
+
/** Header title (a filename); falls back to the language label like fences. */
|
|
23
|
+
title?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
const { code, lang = "txt", icons } = Astro.props;
|
|
25
|
-
const html = await highlightCode(code.replace(/\n+$/u, ""), lang, {
|
|
26
|
+
const { code, lang = "txt", icons, title } = Astro.props;
|
|
27
|
+
const html = await highlightCode(code.replace(/\n+$/u, ""), lang, {
|
|
28
|
+
icons,
|
|
29
|
+
title,
|
|
30
|
+
});
|
|
26
31
|
---
|
|
27
32
|
|
|
28
33
|
<div class="prose max-w-none"><Fragment set:html={html} /></div>
|
|
@@ -114,13 +114,56 @@ const sectionChildren = (nodes: NavNode[], base: string): NavNode[] | null => {
|
|
|
114
114
|
return null;
|
|
115
115
|
};
|
|
116
116
|
|
|
117
|
+
/** Whether a group maps to a header tab (matched on its path or link route). */
|
|
118
|
+
const isTabSection = (node: NavNode, tabPaths: Set<string>): boolean =>
|
|
119
|
+
node.kind === "group" &&
|
|
120
|
+
((node.path !== undefined && tabPaths.has(node.path)) ||
|
|
121
|
+
(node.route !== undefined && tabPaths.has(node.route)));
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Drop the groups that already own a header tab from the tree, at any depth —
|
|
125
|
+
* so a root/un-tabbed route lists only the pages outside every tab's section
|
|
126
|
+
* instead of duplicating each tab as a sidebar group. A container left empty by
|
|
127
|
+
* this pruning is dropped too, so no bare heading is stranded. The root tab
|
|
128
|
+
* (`/`) spans everything, so it never removes anything.
|
|
129
|
+
*/
|
|
130
|
+
const withoutTabSections = (nodes: NavNode[], tabs: NavTab[]): NavNode[] => {
|
|
131
|
+
const tabPaths = new Set(
|
|
132
|
+
tabs.filter((tab) => tab.path !== "/").map((tab) => tab.path)
|
|
133
|
+
);
|
|
134
|
+
if (tabPaths.size === 0) {
|
|
135
|
+
return nodes;
|
|
136
|
+
}
|
|
137
|
+
const prune = (items: NavNode[]): NavNode[] => {
|
|
138
|
+
const kept: NavNode[] = [];
|
|
139
|
+
for (const item of items) {
|
|
140
|
+
if (isTabSection(item, tabPaths)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (item.kind === "group") {
|
|
144
|
+
const children = prune(item.children);
|
|
145
|
+
if (children.length === 0) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
kept.push({ ...item, children });
|
|
149
|
+
} else {
|
|
150
|
+
kept.push(item);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return kept;
|
|
154
|
+
};
|
|
155
|
+
return prune(nodes);
|
|
156
|
+
};
|
|
157
|
+
|
|
117
158
|
/**
|
|
118
159
|
* Scope the sidebar to the active tab's section. With tabs configured, a route
|
|
119
160
|
* under one tab shows only that tab's group — so a multi-section site (e.g.
|
|
120
161
|
* Adapters / API / AI tabs) drills each tab into its own pages instead of one
|
|
121
|
-
* global tree, the way Fumadocs' root folders do.
|
|
122
|
-
*
|
|
123
|
-
*
|
|
162
|
+
* global tree, the way Fumadocs' root folders do. On a route under no tab (or
|
|
163
|
+
* the root `/` tab), the tab-owned groups are hidden so the root sidebar shows
|
|
164
|
+
* only pages that don't belong to a tab. Falls back to the full sidebar when a
|
|
165
|
+
* matched tab maps to no group, or when hiding the tab sections would blank the
|
|
166
|
+
* sidebar, so a route is never left empty.
|
|
124
167
|
*/
|
|
125
168
|
export const sidebarForRoute = (
|
|
126
169
|
sidebar: NavNode[],
|
|
@@ -128,10 +171,11 @@ export const sidebarForRoute = (
|
|
|
128
171
|
route: string
|
|
129
172
|
): NavNode[] => {
|
|
130
173
|
const tab = activeTab(tabs, route);
|
|
131
|
-
if (
|
|
132
|
-
return sidebar;
|
|
174
|
+
if (tab) {
|
|
175
|
+
return sectionChildren(sidebar, tab.path) ?? sidebar;
|
|
133
176
|
}
|
|
134
|
-
|
|
177
|
+
const scoped = withoutTabSections(sidebar, tabs);
|
|
178
|
+
return scoped.length > 0 ? scoped : sidebar;
|
|
135
179
|
};
|
|
136
180
|
|
|
137
181
|
/** Resolve previous/next pages around the current route. */
|
package/src/core/schema.ts
CHANGED
|
@@ -283,6 +283,31 @@ const notionSourceSchema = z.object({
|
|
|
283
283
|
type: z.literal("notion"),
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* A repo's GitHub Releases, materialized as `type: changelog` entries — release
|
|
288
|
+
* notes become the changelog with no files to maintain. A private repo reads a
|
|
289
|
+
* token from `GITHUB_TOKEN`; it is never inlined here.
|
|
290
|
+
*/
|
|
291
|
+
const githubReleasesSourceSchema = z
|
|
292
|
+
.object({
|
|
293
|
+
/** Include draft releases (needs a token with repo write access). */
|
|
294
|
+
drafts: z.boolean().optional(),
|
|
295
|
+
/** Cap the number of releases materialized, newest-first. Default 100. */
|
|
296
|
+
limit: z.number().positive().optional(),
|
|
297
|
+
/** Repository owner (user or org). */
|
|
298
|
+
owner: z.string(),
|
|
299
|
+
/** Opt-in dev polling interval (seconds); omit to freeze for the session. */
|
|
300
|
+
pollInterval: z.number().positive().optional(),
|
|
301
|
+
/** Namespaces the source's routes under `/<prefix>/`; e.g. `changelog`. */
|
|
302
|
+
prefix: z.string().optional(),
|
|
303
|
+
/** Include prereleases. */
|
|
304
|
+
prereleases: z.boolean().optional(),
|
|
305
|
+
/** Repository name. */
|
|
306
|
+
repo: z.string(),
|
|
307
|
+
type: z.literal("github-releases"),
|
|
308
|
+
})
|
|
309
|
+
.strict();
|
|
310
|
+
|
|
286
311
|
/**
|
|
287
312
|
* In-place Mintlify content (`docs.json` + MDX). Powers "bridge mode": Blume
|
|
288
313
|
* reads an unconverted Mintlify project, transforming each page to Blume MDX at
|
|
@@ -328,6 +353,7 @@ const customSourceSchema = z.object({
|
|
|
328
353
|
const contentSourceSchema = z.discriminatedUnion("type", [
|
|
329
354
|
filesystemSourceSchema,
|
|
330
355
|
mdxRemoteSourceSchema,
|
|
356
|
+
githubReleasesSourceSchema,
|
|
331
357
|
sanitySourceSchema,
|
|
332
358
|
notionSourceSchema,
|
|
333
359
|
mintlifySourceSchema,
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import matter from "../frontmatter.ts";
|
|
2
|
+
import {
|
|
3
|
+
hashText,
|
|
4
|
+
loadWithCache,
|
|
5
|
+
pollingWatch,
|
|
6
|
+
snapshotCache,
|
|
7
|
+
} from "./cache.ts";
|
|
8
|
+
import type {
|
|
9
|
+
ContentSource,
|
|
10
|
+
SourceContext,
|
|
11
|
+
SourceEntry,
|
|
12
|
+
SourceLoadResult,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** Options for the built-in GitHub Releases changelog source. */
|
|
16
|
+
export interface GithubReleasesSourceOptions {
|
|
17
|
+
/** GitHub REST API base; overridable for GitHub Enterprise / tests. */
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
/** Include draft releases (needs a token with repo write access). Default off. */
|
|
20
|
+
drafts?: boolean;
|
|
21
|
+
/** Injected for tests; defaults to the global `fetch`. */
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
/** Cap the number of releases materialized, newest-first. Default 100. */
|
|
24
|
+
limit?: number;
|
|
25
|
+
name: string;
|
|
26
|
+
/** Repository owner (user or org). */
|
|
27
|
+
owner: string;
|
|
28
|
+
/** Opt-in dev polling interval (seconds); omit to freeze for the session. */
|
|
29
|
+
pollInterval?: number;
|
|
30
|
+
/** Namespaces the source's routes under `/<prefix>/`; e.g. `changelog`. */
|
|
31
|
+
prefix?: string;
|
|
32
|
+
/** Include prereleases. Default off. */
|
|
33
|
+
prereleases?: boolean;
|
|
34
|
+
/** Repository name. */
|
|
35
|
+
repo: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The subset of the GitHub release payload the adapter reads. */
|
|
39
|
+
interface GithubRelease {
|
|
40
|
+
body: string | null;
|
|
41
|
+
created_at: string;
|
|
42
|
+
draft: boolean;
|
|
43
|
+
html_url: string;
|
|
44
|
+
id: number;
|
|
45
|
+
name: string | null;
|
|
46
|
+
prerelease: boolean;
|
|
47
|
+
published_at: string | null;
|
|
48
|
+
tag_name: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_BASE_URL = "https://api.github.com";
|
|
52
|
+
const DEFAULT_LIMIT = 100;
|
|
53
|
+
const PER_PAGE = 100;
|
|
54
|
+
|
|
55
|
+
const LEADING_V = /^v/iu;
|
|
56
|
+
const NON_SLUG = /[^a-z0-9]+/gu;
|
|
57
|
+
const EDGE_DASHES = /^-+|-+$/gu;
|
|
58
|
+
|
|
59
|
+
/** Slugify a tag into a stable, URL-safe source ref (`v1.2.0` -> `v1-2-0`). */
|
|
60
|
+
const slugifyTag = (tag: string): string =>
|
|
61
|
+
tag.toLowerCase().replaceAll(NON_SLUG, "-").replaceAll(EDGE_DASHES, "");
|
|
62
|
+
|
|
63
|
+
/** Build request headers, reading `GITHUB_TOKEN` fresh at call time. */
|
|
64
|
+
const githubHeaders = (): Headers => {
|
|
65
|
+
const headers = new Headers({ Accept: "application/vnd.github+json" });
|
|
66
|
+
const token = process.env.GITHUB_TOKEN;
|
|
67
|
+
if (token) {
|
|
68
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
69
|
+
}
|
|
70
|
+
return headers;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lower one release to a staged Markdown entry: the notes become the body and
|
|
75
|
+
* `type: changelog` frontmatter (title/date/version/category) drives the
|
|
76
|
+
* generated `/changelog` timeline and RSS feed.
|
|
77
|
+
*/
|
|
78
|
+
const releaseToEntry = (release: GithubRelease): SourceEntry => {
|
|
79
|
+
const version = release.tag_name.replace(LEADING_V, "");
|
|
80
|
+
const title = release.name?.trim() || release.tag_name;
|
|
81
|
+
const date = release.published_at ?? release.created_at;
|
|
82
|
+
const category = release.prerelease ? "Prerelease" : "Release";
|
|
83
|
+
const body = (release.body ?? "").replaceAll("\r\n", "\n").trim();
|
|
84
|
+
const data = {
|
|
85
|
+
changelog: { category, version },
|
|
86
|
+
date,
|
|
87
|
+
title,
|
|
88
|
+
type: "changelog",
|
|
89
|
+
};
|
|
90
|
+
const raw = matter.stringify(`${body}\n`, data);
|
|
91
|
+
const ref = `${slugifyTag(release.tag_name) || `release-${release.id}`}.md`;
|
|
92
|
+
return {
|
|
93
|
+
body: { format: "md", text: body },
|
|
94
|
+
data,
|
|
95
|
+
editUrl: release.html_url,
|
|
96
|
+
hash: hashText(raw),
|
|
97
|
+
lastModified: date,
|
|
98
|
+
raw,
|
|
99
|
+
ref,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* GitHub Releases content source. Pulls a repo's releases from the REST API and
|
|
105
|
+
* materializes each as a `type: changelog` entry, so a project's release notes
|
|
106
|
+
* become its changelog with no files to maintain. A private repo authenticates
|
|
107
|
+
* with `GITHUB_TOKEN`. A snapshot under `.blume/cache/<source>/` keeps rebuilds
|
|
108
|
+
* offline-tolerant.
|
|
109
|
+
*/
|
|
110
|
+
export const githubReleasesSource = (
|
|
111
|
+
options: GithubReleasesSourceOptions,
|
|
112
|
+
ctx: SourceContext
|
|
113
|
+
): ContentSource => {
|
|
114
|
+
const doFetch = options.fetchImpl ?? globalThis.fetch;
|
|
115
|
+
const base = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/u, "");
|
|
116
|
+
const max = options.limit ?? DEFAULT_LIMIT;
|
|
117
|
+
const cache = snapshotCache(ctx.cacheDir);
|
|
118
|
+
let snapshot = new Map<string, SourceEntry>();
|
|
119
|
+
|
|
120
|
+
const include = (release: GithubRelease): boolean =>
|
|
121
|
+
(options.drafts || !release.draft) &&
|
|
122
|
+
(options.prereleases || !release.prerelease);
|
|
123
|
+
|
|
124
|
+
const fetchPage = async (page: number): Promise<GithubRelease[]> => {
|
|
125
|
+
const url = `${base}/repos/${options.owner}/${options.repo}/releases?per_page=${PER_PAGE}&page=${page}`;
|
|
126
|
+
const res = await doFetch(url, { headers: githubHeaders() });
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
throw new Error(`${url} -> ${res.status}`);
|
|
129
|
+
}
|
|
130
|
+
return (await res.json()) as GithubRelease[];
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const fetchReleases = async (): Promise<GithubRelease[]> => {
|
|
134
|
+
const collected: GithubRelease[] = [];
|
|
135
|
+
let page = 1;
|
|
136
|
+
while (collected.length < max) {
|
|
137
|
+
// oxlint-disable-next-line no-await-in-loop -- pages are sequential: each page's length decides whether another exists.
|
|
138
|
+
const batch = await fetchPage(page);
|
|
139
|
+
collected.push(...batch.filter(include));
|
|
140
|
+
if (batch.length < PER_PAGE) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
page += 1;
|
|
144
|
+
}
|
|
145
|
+
return collected.slice(0, max);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const load = async (): Promise<SourceLoadResult> => {
|
|
149
|
+
try {
|
|
150
|
+
const result = await loadWithCache(
|
|
151
|
+
options.name,
|
|
152
|
+
cache,
|
|
153
|
+
async () => {
|
|
154
|
+
const releases = await fetchReleases();
|
|
155
|
+
return releases.map(releaseToEntry);
|
|
156
|
+
},
|
|
157
|
+
ctx.refresh ?? true
|
|
158
|
+
);
|
|
159
|
+
snapshot = new Map(result.entries.map((entry) => [entry.ref, entry]));
|
|
160
|
+
return result;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// A changelog is supplementary. When releases can't be fetched and nothing
|
|
163
|
+
// is cached (e.g. CI or a deploy without a `GITHUB_TOKEN` for a private
|
|
164
|
+
// repo), degrade to an empty changelog with a warning rather than failing
|
|
165
|
+
// the whole build.
|
|
166
|
+
snapshot = new Map();
|
|
167
|
+
return {
|
|
168
|
+
diagnostics: [
|
|
169
|
+
{
|
|
170
|
+
code: "BLUME_SOURCE_UNAVAILABLE",
|
|
171
|
+
message: `Source "${options.name}" could not fetch GitHub releases (${(error as Error).message}); the changelog will be empty. Set GITHUB_TOKEN to include it (required for a private repository).`,
|
|
172
|
+
severity: "warning",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
entries: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const read = async (ref: string): Promise<string> => {
|
|
181
|
+
const cached = snapshot.get(ref);
|
|
182
|
+
if (cached) {
|
|
183
|
+
return cached.raw ?? cached.body.text;
|
|
184
|
+
}
|
|
185
|
+
const all = await cache.read();
|
|
186
|
+
const entry = all.find((e) => e.ref === ref);
|
|
187
|
+
return entry?.raw ?? entry?.body.text ?? "";
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
load,
|
|
192
|
+
name: options.name,
|
|
193
|
+
prefix: options.prefix,
|
|
194
|
+
read,
|
|
195
|
+
staged: true,
|
|
196
|
+
watch: options.pollInterval
|
|
197
|
+
? pollingWatch(load, options.pollInterval)
|
|
198
|
+
: undefined,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
@@ -3,6 +3,7 @@ import { join } from "pathe";
|
|
|
3
3
|
import type { ContentSourceConfig, ResolvedConfig } from "../schema.ts";
|
|
4
4
|
import type { ProjectContext } from "../types.ts";
|
|
5
5
|
import { filesystemSource } from "./filesystem.ts";
|
|
6
|
+
import { githubReleasesSource } from "./github-releases.ts";
|
|
6
7
|
import { mdxRemoteSource } from "./mdx-remote.ts";
|
|
7
8
|
import { mintlifySource } from "./mintlify.ts";
|
|
8
9
|
import { notionSource } from "./notion.ts";
|
|
@@ -106,6 +107,21 @@ const buildSource = (
|
|
|
106
107
|
sourceContext(context, name, runtime)
|
|
107
108
|
);
|
|
108
109
|
}
|
|
110
|
+
if (def.type === "github-releases") {
|
|
111
|
+
return githubReleasesSource(
|
|
112
|
+
{
|
|
113
|
+
drafts: def.drafts,
|
|
114
|
+
limit: def.limit,
|
|
115
|
+
name,
|
|
116
|
+
owner: def.owner,
|
|
117
|
+
pollInterval: def.pollInterval,
|
|
118
|
+
prefix: def.prefix,
|
|
119
|
+
prereleases: def.prereleases,
|
|
120
|
+
repo: def.repo,
|
|
121
|
+
},
|
|
122
|
+
sourceContext(context, name, runtime)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
109
125
|
return mdxRemoteSource(
|
|
110
126
|
{
|
|
111
127
|
files: def.files,
|
package/src/markdown/index.ts
CHANGED
|
@@ -128,9 +128,26 @@ const astroCodeClassTransformer = (extra?: string): ShikiTransformer =>
|
|
|
128
128
|
},
|
|
129
129
|
}) as unknown as ShikiTransformer;
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Tag the `<pre>` with `data-language` — raw `codeToHtml` omits it (unlike
|
|
133
|
+
* Astro's Markdown Shiki), and the theme's code header keys off it. Applied only
|
|
134
|
+
* on the titled path so a titled standalone block renders the same header bar a
|
|
135
|
+
* fence would, while header-less panes (e.g. the Component source view) stay
|
|
136
|
+
* untouched.
|
|
137
|
+
*/
|
|
138
|
+
const languageAttrTransformer = (lang: string): ShikiTransformer =>
|
|
139
|
+
({
|
|
140
|
+
name: "blume:data-language",
|
|
141
|
+
pre(node: { properties: Record<string, unknown> }) {
|
|
142
|
+
node.properties.dataLanguage ??= lang;
|
|
143
|
+
},
|
|
144
|
+
}) as unknown as ShikiTransformer;
|
|
145
|
+
|
|
131
146
|
export interface HighlightCodeOptions extends BlumeShikiOptions {
|
|
132
147
|
/** Extra `<pre>` class names, e.g. `blume-source` for a height-capped pane. */
|
|
133
148
|
className?: string;
|
|
149
|
+
/** Header title (a filename), matching a fence's `title="..."` meta. */
|
|
150
|
+
title?: string;
|
|
134
151
|
}
|
|
135
152
|
|
|
136
153
|
/**
|
|
@@ -152,10 +169,17 @@ export const highlightCode = async (
|
|
|
152
169
|
return await codeToHtml(code, {
|
|
153
170
|
defaultColor: false,
|
|
154
171
|
lang,
|
|
172
|
+
// The code-title transformer reads the fence meta; feeding a `title="..."`
|
|
173
|
+
// string here gives non-fence callers (e.g. `<CodeBlock title>`) the same
|
|
174
|
+
// `data-title` header a Markdown fence gets.
|
|
175
|
+
meta: options.title
|
|
176
|
+
? { __raw: `title="${options.title.replaceAll('"', "")}"` }
|
|
177
|
+
: undefined,
|
|
155
178
|
themes: CODE_THEMES,
|
|
156
179
|
transformers: [
|
|
157
180
|
...blumeShikiTransformers({ icons: options.icons }),
|
|
158
181
|
astroCodeClassTransformer(options.className),
|
|
182
|
+
...(options.title ? [languageAttrTransformer(lang)] : []),
|
|
159
183
|
],
|
|
160
184
|
});
|
|
161
185
|
} catch {
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: v0.1.0
|
|
3
|
-
type: changelog
|
|
4
|
-
date: 2026-06-01
|
|
5
|
-
changelog:
|
|
6
|
-
version: 0.1.0
|
|
7
|
-
category: Release
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
The first public release of Blume — a markdown-first docs framework on Astro and
|
|
11
|
-
Vite. Drop in Markdown or MDX, run `blume dev`, and ship a production-grade docs
|
|
12
|
-
site with search, theming, and AI-ready output.
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: v0.2.0
|
|
3
|
-
type: changelog
|
|
4
|
-
date: 2026-06-24
|
|
5
|
-
changelog:
|
|
6
|
-
version: 0.2.0
|
|
7
|
-
category: Features
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
A big batch of built-in components landed: **columns**, **frames**, **trees**,
|
|
11
|
-
**tooltips**, **code groups**, **panels**, **tiles**, and **fields**. Code groups
|
|
12
|
-
now render as proper language tabs, and the changelog gets this timeline.
|
|
13
|
-
|
|
14
|
-
- New `Accordion` / `AccordionItem`, `Expandable`, `Tooltip`, `Frame`, `Color`
|
|
15
|
-
- `CodeGroup` tabs with flush code blocks
|
|
16
|
-
- Redesigned `Prompt` with a copy-to-clipboard button
|