blume 0.5.4 → 0.6.1

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.
Files changed (57) hide show
  1. package/dist/cli/index.js +759 -406
  2. package/dist/cli/index.js.map +27 -25
  3. package/dist/types/core/config-input.d.ts +759 -0
  4. package/dist/types/core/config.d.ts +126 -3
  5. package/dist/types/core/data.d.ts +4 -0
  6. package/dist/types/core/i18n-ui.d.ts +50 -0
  7. package/dist/types/core/schema.d.ts +334 -62
  8. package/dist/types/core/types.d.ts +8 -0
  9. package/dist/types/index.d.ts +2 -1
  10. package/docs/advanced/changelog.mdx +10 -2
  11. package/docs/configuration/ai.mdx +56 -0
  12. package/docs/configuration/index.mdx +0 -2
  13. package/docs/configuration/seo.mdx +59 -1
  14. package/docs/configuration/theming.mdx +14 -9
  15. package/docs/content/meta.mdx +3 -17
  16. package/docs/content/navigation.mdx +41 -4
  17. package/docs/content/syntax.mdx +4 -8
  18. package/package.json +3 -1
  19. package/src/ai/agent-readability.ts +97 -0
  20. package/src/ai/ask-context.ts +131 -8
  21. package/src/ai/ask-data.ts +4 -1
  22. package/src/astro/generate.ts +40 -11
  23. package/src/astro/templates.ts +90 -10
  24. package/src/cli/commands/build.ts +41 -1
  25. package/src/cli/commands/dev.ts +31 -14
  26. package/src/cli/dev-lock.ts +94 -21
  27. package/src/components/content/GithubInfo.astro +11 -10
  28. package/src/components/content/TypeTable.astro +8 -3
  29. package/src/components/content/Update.astro +12 -2
  30. package/src/components/content/changelog-element.ts +62 -0
  31. package/src/components/islands/AskAI.astro +66 -2
  32. package/src/components/islands/ask-ai.tsx +289 -53
  33. package/src/components/layout/Header.astro +1 -1
  34. package/src/components/layout/NavTree.astro +1 -1
  35. package/src/components/layout/PageActions.astro +73 -30
  36. package/src/components/layout/RootLayout.astro +79 -10
  37. package/src/core/config-input.ts +933 -0
  38. package/src/core/config.ts +126 -3
  39. package/src/core/data.ts +4 -0
  40. package/src/core/graph.ts +7 -2
  41. package/src/core/i18n-ui.ts +5 -0
  42. package/src/core/nav-diagnostics.ts +7 -0
  43. package/src/core/navigation.ts +38 -12
  44. package/src/core/schema.ts +130 -22
  45. package/src/core/sources/filesystem.ts +5 -1
  46. package/src/core/sources/watch.ts +43 -12
  47. package/src/core/types.ts +9 -0
  48. package/src/deploy/adapter-output.ts +82 -0
  49. package/src/deploy/robots.ts +37 -4
  50. package/src/index.ts +1 -1
  51. package/src/markdown/index.ts +28 -30
  52. package/src/markdown/math.ts +3 -2
  53. package/src/openapi/scalar.ts +1 -1
  54. package/src/registry/eject.ts +21 -14
  55. package/src/search/documents.ts +9 -2
  56. package/src/theme/entry.ts +7 -3
  57. package/src/theme/palette.ts +21 -14
@@ -14,26 +14,48 @@ import { logger } from "./log.ts";
14
14
  /**
15
15
  * A best-effort PID lock in the shared `.blume/` runtime dir. `blume dev`
16
16
  * regenerates and serves `.blume` continuously, so a concurrent `build`,
17
- * `eject`, or `sync --force` that regenerates or deletes it out from under the
17
+ * `eject`, or second `dev` that regenerates or deletes it out from under the
18
18
  * running Vite server corrupts the dev session. The lock lets those commands
19
- * detect a live dev server and refuse.
19
+ * detect a live dev server and refuse — and, because it records the server's
20
+ * port, point the caller (often an agent that just tried to start its own
21
+ * server) at the URL to reuse instead.
20
22
  */
21
23
 
24
+ export interface DevLockInfo {
25
+ pid: number;
26
+ /** Port the dev server is bound to, when known. */
27
+ port?: number;
28
+ }
29
+
22
30
  const lockPath = (outDir: string): string => join(outDir, "dev.lock");
23
31
 
32
+ const isValidPid = (pid: unknown): pid is number =>
33
+ typeof pid === "number" && Number.isInteger(pid) && pid > 0;
34
+
24
35
  /**
25
- * Whether another live `blume dev` holds the lock on `outDir`. A lock left by a
26
- * process that has since exited (stale) is treated as absent.
36
+ * Parse a lock file body. Current locks are JSON (`{"pid":123,"port":3001}`);
37
+ * a bare integer (the pre-port format) still parses as a pid-only lock.
27
38
  */
28
- export const isDevLocked = (outDir: string): boolean => {
29
- const path = lockPath(outDir);
30
- if (!existsSync(path)) {
31
- return false;
39
+ const parseLock = (raw: string): DevLockInfo | null => {
40
+ let data: unknown;
41
+ try {
42
+ data = JSON.parse(raw.trim());
43
+ } catch {
44
+ return null;
32
45
  }
33
- const pid = Number.parseInt(readFileSync(path, "utf-8").trim(), 10);
34
- if (!(Number.isInteger(pid) && pid > 0)) {
35
- return false;
46
+ if (isValidPid(data)) {
47
+ return { pid: data };
36
48
  }
49
+ if (typeof data === "object" && data !== null) {
50
+ const { pid, port } = data as { pid?: unknown; port?: unknown };
51
+ if (isValidPid(pid)) {
52
+ return typeof port === "number" ? { pid, port } : { pid };
53
+ }
54
+ }
55
+ return null;
56
+ };
57
+
58
+ const isProcessAlive = (pid: number): boolean => {
37
59
  try {
38
60
  // Signal 0 probes liveness without actually signaling the process.
39
61
  process.kill(pid, 0);
@@ -45,15 +67,53 @@ export const isDevLocked = (outDir: string): boolean => {
45
67
  }
46
68
  };
47
69
 
70
+ /**
71
+ * Read the lock on `outDir` held by a live `blume dev`, or null. A lock left
72
+ * by a process that has since exited (stale) is treated as absent.
73
+ */
74
+ export const readDevLock = (outDir: string): DevLockInfo | null => {
75
+ const path = lockPath(outDir);
76
+ if (!existsSync(path)) {
77
+ return null;
78
+ }
79
+ const lock = parseLock(readFileSync(path, "utf-8"));
80
+ return lock && isProcessAlive(lock.pid) ? lock : null;
81
+ };
82
+
83
+ /** Whether another live `blume dev` holds the lock on `outDir`. */
84
+ export const isDevLocked = (outDir: string): boolean =>
85
+ readDevLock(outDir) !== null;
86
+
87
+ const writeLock = (outDir: string, port?: number): void => {
88
+ writeFileSync(
89
+ lockPath(outDir),
90
+ JSON.stringify({
91
+ pid: process.pid,
92
+ ...(port === undefined ? {} : { port }),
93
+ })
94
+ );
95
+ };
96
+
97
+ const ownsLock = (outDir: string): boolean => {
98
+ const path = lockPath(outDir);
99
+ if (!existsSync(path)) {
100
+ return false;
101
+ }
102
+ try {
103
+ return parseLock(readFileSync(path, "utf-8"))?.pid === process.pid;
104
+ } catch {
105
+ return false;
106
+ }
107
+ };
108
+
48
109
  /**
49
110
  * Write the current process's dev lock into `outDir` and return a release
50
111
  * function. The release only removes the file if it's still ours, so a newer
51
112
  * dev server's lock is never clobbered.
52
113
  */
53
- export const acquireDevLock = (outDir: string): (() => void) => {
54
- const path = lockPath(outDir);
114
+ export const acquireDevLock = (outDir: string, port?: number): (() => void) => {
55
115
  mkdirSync(outDir, { recursive: true });
56
- writeFileSync(path, String(process.pid));
116
+ writeLock(outDir, port);
57
117
  let released = false;
58
118
  return () => {
59
119
  if (released) {
@@ -61,11 +121,8 @@ export const acquireDevLock = (outDir: string): (() => void) => {
61
121
  }
62
122
  released = true;
63
123
  try {
64
- if (
65
- existsSync(path) &&
66
- readFileSync(path, "utf-8").trim() === String(process.pid)
67
- ) {
68
- rmSync(path, { force: true });
124
+ if (ownsLock(outDir)) {
125
+ rmSync(lockPath(outDir), { force: true });
69
126
  }
70
127
  } catch {
71
128
  // Best-effort cleanup; a stale lock is handled by the liveness check.
@@ -73,6 +130,21 @@ export const acquireDevLock = (outDir: string): (() => void) => {
73
130
  };
74
131
  };
75
132
 
133
+ /**
134
+ * Rewrite this process's lock with the port the server actually bound (the
135
+ * lock is acquired before the server starts, and Vite may bump a busy port).
136
+ * A lock owned by another process is left alone.
137
+ */
138
+ export const updateDevLockPort = (outDir: string, port: number): void => {
139
+ if (ownsLock(outDir)) {
140
+ writeLock(outDir, port);
141
+ }
142
+ };
143
+
144
+ /** Human-readable location of a locked dev server, e.g. " at http://localhost:3001". */
145
+ export const describeDevLock = (lock: DevLockInfo): string =>
146
+ lock.port === undefined ? "" : ` at http://localhost:${lock.port}`;
147
+
76
148
  /**
77
149
  * Exit with an error when a live `blume dev` owns the runtime dir under `root`.
78
150
  * `action` names the operation being refused (e.g. "building"). `runtimeDir`
@@ -85,9 +157,10 @@ export const refuseIfDevRunning = (
85
157
  action: string,
86
158
  runtimeDir?: string
87
159
  ): void => {
88
- if (isDevLocked(resolveRuntimeDir(root, runtimeDir))) {
160
+ const lock = readDevLock(resolveRuntimeDir(root, runtimeDir));
161
+ if (lock) {
89
162
  logger.error(
90
- `A \`blume dev\` server is running against .blume; ${action} would corrupt it. Stop the dev server, or re-run with --isolated to build/verify against .blume-verify without touching it.`
163
+ `A \`blume dev\` server is running${describeDevLock(lock)}; ${action} would corrupt its .blume runtime. Reuse that server, stop it first, or re-run with --isolated to build/verify against .blume-verify without touching it.`
91
164
  );
92
165
  process.exit(1);
93
166
  }
@@ -3,10 +3,17 @@
3
3
  // at build time (no client JS). Pass `owner`/`repo`, or omit them to use the
4
4
  // repo from blume.config. A `GITHUB_TOKEN` env var lifts the API rate limit.
5
5
  // If the API is unreachable the card still renders, just without counts.
6
+ import { resolveIcon } from "../../theme/icons.ts";
6
7
  import data from "blume:data";
7
8
  import { GITHUB_MARK } from "../github-mark.ts";
8
9
  import { fetchRepositoryInfo } from "./github-info.ts";
9
10
 
11
+ // Star/fork glyphs resolve from Lucide at build time; the `<svg>` wrappers below
12
+ // supply size and the shared `statIcon` class. (The GitHub octocat above is a
13
+ // brand mark, not a Lucide icon, so it stays as GITHUB_MARK.)
14
+ const starIcon = resolveIcon("star")?.body ?? "";
15
+ const forkIcon = resolveIcon("git-fork")?.body ?? "";
16
+
10
17
  interface Props {
11
18
  owner?: string;
12
19
  repo?: string;
@@ -77,9 +84,8 @@ const statIcon =
77
84
  stroke-width="2"
78
85
  viewBox="0 0 24 24"
79
86
  width="14"
80
- >
81
- <path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
82
- </svg>
87
+ set:html={starIcon}
88
+ />
83
89
  <span>{numbers.format(info.stars)}</span>
84
90
  </span>
85
91
  <span class="flex items-center gap-1.5">
@@ -94,13 +100,8 @@ const statIcon =
94
100
  stroke-width="2"
95
101
  viewBox="0 0 24 24"
96
102
  width="14"
97
- >
98
- <circle cx="12" cy="18" r="3" />
99
- <circle cx="6" cy="6" r="3" />
100
- <circle cx="18" cy="6" r="3" />
101
- <path d="M18 9v2c0 .6-.4 1-1 1H7c-.6 0-1-.4-1-1V9" />
102
- <path d="M12 12v3" />
103
- </svg>
103
+ set:html={forkIcon}
104
+ />
104
105
  <span>{numbers.format(info.forks)}</span>
105
106
  </span>
106
107
  </span>
@@ -4,6 +4,12 @@
4
4
  // the accent-colored prop name (with a `?` suffix when optional) and its type;
5
5
  // expanded, the row lifts into a card showing the description and a Type/Default
6
6
  // detail grid. Uses native <details>/<summary> so the core stays React-free.
7
+ import { resolveIcon } from "../../theme/icons.ts";
8
+
9
+ // The row disclosure caret; resolved from Lucide at build time and injected into
10
+ // the summary's `<svg>` (which supplies size and the rotate-on-open transition).
11
+ const chevronDown = resolveIcon("chevron-down")?.body ?? "";
12
+
7
13
  interface TypeEntry {
8
14
  default?: string;
9
15
  description?: string;
@@ -64,9 +70,8 @@ const columns = "grid grid-cols-[1fr_2fr_auto] items-center gap-4";
64
70
  stroke-width="2"
65
71
  viewBox="0 0 24 24"
66
72
  width="16"
67
- >
68
- <path d="m6 9 6 6 6-6" />
69
- </svg>
73
+ set:html={chevronDown}
74
+ />
70
75
  </summary>
71
76
 
72
77
  <div class="border-border border-t px-3 py-3 text-xs">
@@ -6,6 +6,8 @@ interface RssMetadata {
6
6
 
7
7
  interface Props {
8
8
  description?: string;
9
+ /** Link the heading to a dedicated page; falls back to the in-page anchor. */
10
+ href?: string;
9
11
  id?: string;
10
12
  label?: string;
11
13
  rss?: RssMetadata;
@@ -13,7 +15,15 @@ interface Props {
13
15
  title?: string;
14
16
  }
15
17
 
16
- const { description, id: providedId, label, rss, tags, title } = Astro.props;
18
+ const {
19
+ description,
20
+ href,
21
+ id: providedId,
22
+ label,
23
+ rss,
24
+ tags,
25
+ title,
26
+ } = Astro.props;
17
27
  const slugify = (text: string): string =>
18
28
  text
19
29
  .toLowerCase()
@@ -37,7 +47,7 @@ const tagList = Array.isArray(tags) ? tags : tags ? [tags] : [];
37
47
  <header class="md:border-border md:border-e md:pe-4">
38
48
  <a
39
49
  class="font-semibold text-foreground text-sm no-underline hover:text-accent"
40
- href={`#${id}`}
50
+ href={href ?? `#${id}`}
41
51
  >
42
52
  {updateLabel}
43
53
  </a>
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Client behaviour for the `<blume-changelog>` custom element wrapping the
3
+ * generated changelog timeline when its releases are semver-versioned. The
4
+ * newest major line stays visible; every older major is collapsed into a group
5
+ * revealed one major at a time by the "Show N.x releases" button at the bottom.
6
+ *
7
+ * Pure progressive enhancement: the server renders every release in document
8
+ * order, and this element hides the older-major groups on connect — so a no-JS
9
+ * visitor (or a crawler) still sees the complete history, and the button does
10
+ * nothing until the script upgrades it.
11
+ *
12
+ * Imported for its side effect (registers the element) from the changelog page.
13
+ */
14
+
15
+ class BlumeChangelog extends HTMLElement {
16
+ connectedCallback() {
17
+ const groups = [
18
+ ...this.querySelectorAll<HTMLElement>("[data-changelog-major]"),
19
+ ];
20
+ const button = this.querySelector<HTMLButtonElement>(
21
+ "[data-changelog-more]"
22
+ );
23
+ if (groups.length === 0 || !button) {
24
+ return;
25
+ }
26
+
27
+ for (const group of groups) {
28
+ group.hidden = true;
29
+ // Focusable only programmatically, so revealing a group can move focus to
30
+ // it for keyboard and screen-reader users without adding a tab stop.
31
+ group.tabIndex = -1;
32
+ }
33
+
34
+ let revealed = 0;
35
+ const sync = () => {
36
+ const next = groups[revealed];
37
+ if (next) {
38
+ button.textContent = `Show ${next.dataset.changelogLabel} releases`;
39
+ button.hidden = false;
40
+ } else {
41
+ button.hidden = true;
42
+ }
43
+ };
44
+
45
+ button.addEventListener("click", () => {
46
+ const next = groups[revealed];
47
+ if (!next) {
48
+ return;
49
+ }
50
+ next.hidden = false;
51
+ revealed += 1;
52
+ sync();
53
+ next.focus();
54
+ });
55
+
56
+ sync();
57
+ }
58
+ }
59
+
60
+ if (!customElements.get("blume-changelog")) {
61
+ customElements.define("blume-changelog", BlumeChangelog);
62
+ }
@@ -1,12 +1,76 @@
1
1
  ---
2
2
  import type { UIStrings } from "../../core/i18n-ui.ts";
3
+ import { resolveIcon } from "../../theme/icons.ts";
3
4
  import AskAI from "./ask-ai.tsx";
4
5
 
6
+ interface Suggestion {
7
+ icon?: string;
8
+ label: string;
9
+ }
10
+
5
11
  interface Props {
6
12
  strings?: UIStrings["ask"];
13
+ suggestions?: Suggestion[];
7
14
  }
8
15
 
9
- const { strings } = Astro.props;
16
+ const { strings, suggestions = [] } = Astro.props;
17
+
18
+ // Icons resolve to inline SVG here (server-only module), so the client island
19
+ // gets ready-to-render markup rather than a name it can't resolve. Bare Lucide
20
+ // bodies carry no styling, so wrap them in a stroke-styled root like Icon.astro.
21
+ const svgFor = (name: string | undefined): string | null => {
22
+ const resolved = resolveIcon(name ?? "sparkles") ?? resolveIcon("sparkles");
23
+ return resolved
24
+ ? `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${resolved.body}</svg>`
25
+ : null;
26
+ };
27
+
28
+ const items = suggestions.map((suggestion) => ({
29
+ icon: svgFor(suggestion.icon),
30
+ label: suggestion.label,
31
+ }));
32
+
33
+ // The panel's own chrome glyphs resolve here too, so the client island carries
34
+ // no icon data. Each value is the bare Lucide body; the island's <Glyph> wraps
35
+ // it in a sized, stroke-styled <svg>. Keyed by role → Lucide icon name.
36
+ const bodyFor = (name: string): string => resolveIcon(name)?.body ?? "";
37
+ const icons = {
38
+ arrowUp: bodyFor("arrow-up"),
39
+ chat: bodyFor("message-circle"),
40
+ clear: bodyFor("trash-2"),
41
+ close: bodyFor("chevrons-right"),
42
+ copy: bodyFor("copy"),
43
+ };
10
44
  ---
11
45
 
12
- <AskAI client:load strings={strings} />
46
+ <AskAI client:load icons={icons} strings={strings} suggestions={items} />
47
+
48
+ <style is:global>
49
+ :root {
50
+ --blume-ask-width: min(30rem, 100vw);
51
+ }
52
+
53
+ /* Desktop docks the panel and shrinks the page to fit, like vercel.com/docs.
54
+ On smaller screens the panel is a full-width overlay (no push). */
55
+ @media (min-width: 1024px) {
56
+ body {
57
+ transition: padding-inline-end 0.2s ease-out;
58
+ }
59
+
60
+ body[data-blume-ask="open"] {
61
+ padding-inline-end: var(--blume-ask-width);
62
+ }
63
+ }
64
+
65
+ /* The table of contents only shows at xl; reclaim its column for the article
66
+ while the panel is open so the shrunken content still has room to breathe. */
67
+ @media (min-width: 1280px) {
68
+ body[data-blume-ask="open"] [data-blume-toc] {
69
+ display: none;
70
+ }
71
+
72
+ body[data-blume-ask="open"] [data-blume-doc-grid] {
73
+ grid-template-columns: 17.5rem minmax(0, 1fr);
74
+ }
75
+ }
76
+ </style>