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/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);
@@ -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, { icons });
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. Falls back to the full
122
- * sidebar when no tab matches (or the tab maps to no group), so a route is
123
- * never left with a blank sidebar.
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 (!tab) {
132
- return sidebar;
174
+ if (tab) {
175
+ return sectionChildren(sidebar, tab.path) ?? sidebar;
133
176
  }
134
- return sectionChildren(sidebar, tab.path) ?? sidebar;
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. */
@@ -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,
@@ -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