dayhand 0.0.0-stage → 0.14.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.
Files changed (191) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +100 -2
  3. package/backend/convex/_generated/api.d.ts +181 -0
  4. package/backend/convex/_generated/api.js +23 -0
  5. package/backend/convex/_generated/dataModel.d.ts +60 -0
  6. package/backend/convex/_generated/server.d.ts +162 -0
  7. package/backend/convex/_generated/server.js +101 -0
  8. package/backend/convex/apiKeys.ts +171 -0
  9. package/backend/convex/app.ts +197 -0
  10. package/backend/convex/auth.config.ts +28 -0
  11. package/backend/convex/calendar.ts +61 -0
  12. package/backend/convex/calendarFeeds.ts +105 -0
  13. package/backend/convex/callLimits.ts +67 -0
  14. package/backend/convex/connections.ts +28 -0
  15. package/backend/convex/crons.ts +18 -0
  16. package/backend/convex/devices.ts +224 -0
  17. package/backend/convex/http.ts +56 -0
  18. package/backend/convex/lib/activity.ts +155 -0
  19. package/backend/convex/lib/agentControls.ts +146 -0
  20. package/backend/convex/lib/agentWork.ts +250 -0
  21. package/backend/convex/lib/appToken.ts +68 -0
  22. package/backend/convex/lib/approvals.ts +311 -0
  23. package/backend/convex/lib/args.ts +175 -0
  24. package/backend/convex/lib/backlogMd.ts +309 -0
  25. package/backend/convex/lib/backlogOps.ts +361 -0
  26. package/backend/convex/lib/brief.ts +132 -0
  27. package/backend/convex/lib/cimd.ts +57 -0
  28. package/backend/convex/lib/core.ts +380 -0
  29. package/backend/convex/lib/dates.ts +114 -0
  30. package/backend/convex/lib/env.ts +19 -0
  31. package/backend/convex/lib/exportPlan.ts +98 -0
  32. package/backend/convex/lib/findTasks.ts +119 -0
  33. package/backend/convex/lib/frontmatter.ts +165 -0
  34. package/backend/convex/lib/guide.ts +286 -0
  35. package/backend/convex/lib/health.ts +115 -0
  36. package/backend/convex/lib/http.ts +27 -0
  37. package/backend/convex/lib/ical.ts +243 -0
  38. package/backend/convex/lib/importFormats.ts +429 -0
  39. package/backend/convex/lib/importOps.ts +145 -0
  40. package/backend/convex/lib/jsonSchema.ts +50 -0
  41. package/backend/convex/lib/limits.ts +42 -0
  42. package/backend/convex/lib/mcpContent.ts +220 -0
  43. package/backend/convex/lib/mcpProtocol.ts +128 -0
  44. package/backend/convex/lib/mcpTools.ts +505 -0
  45. package/backend/convex/lib/oauthConfig.ts +59 -0
  46. package/backend/convex/lib/oauthPage.ts +125 -0
  47. package/backend/convex/lib/oauthRules.ts +159 -0
  48. package/backend/convex/lib/openapi.ts +230 -0
  49. package/backend/convex/lib/outputSchemas.ts +2901 -0
  50. package/backend/convex/lib/problem.ts +69 -0
  51. package/backend/convex/lib/proposals.ts +172 -0
  52. package/backend/convex/lib/repeat.ts +185 -0
  53. package/backend/convex/lib/restRoutes.ts +294 -0
  54. package/backend/convex/lib/rrule.ts +171 -0
  55. package/backend/convex/lib/secrets.ts +135 -0
  56. package/backend/convex/lib/settingsOps.ts +47 -0
  57. package/backend/convex/lib/taskOps.ts +304 -0
  58. package/backend/convex/lib/toolResults.ts +50 -0
  59. package/backend/convex/lib/validators.ts +143 -0
  60. package/backend/convex/lib/views.ts +145 -0
  61. package/backend/convex/lib/vtimezone.ts +151 -0
  62. package/backend/convex/lib/webhookEvents.ts +160 -0
  63. package/backend/convex/lib/webhookSign.ts +30 -0
  64. package/backend/convex/lib/webhookTarget.ts +75 -0
  65. package/backend/convex/mcp.ts +405 -0
  66. package/backend/convex/migrations.ts +91 -0
  67. package/backend/convex/oauth.ts +388 -0
  68. package/backend/convex/oauthHttp.ts +381 -0
  69. package/backend/convex/oauthOwner.ts +173 -0
  70. package/backend/convex/ops.ts +205 -0
  71. package/backend/convex/pairing.ts +178 -0
  72. package/backend/convex/rest.ts +254 -0
  73. package/backend/convex/restAccount.ts +56 -0
  74. package/backend/convex/restImport.ts +73 -0
  75. package/backend/convex/schema.ts +435 -0
  76. package/backend/convex/tsconfig.json +18 -0
  77. package/backend/convex/webhooks.ts +339 -0
  78. package/backend/package-lock.json +3068 -0
  79. package/backend/package.json +33 -0
  80. package/backend/scripts/app-token.mjs +79 -0
  81. package/backend/scripts/fetch-local-backend.mjs +72 -0
  82. package/backend/scripts/gen-output-schemas.mjs +60 -0
  83. package/backend/scripts/jwt.mjs +67 -0
  84. package/backend/scripts/local-backend-lib.mjs +123 -0
  85. package/backend/scripts/local-backend.mjs +81 -0
  86. package/backend/scripts/setup-convex.mjs +158 -0
  87. package/backend/scripts/setup-lib.mjs +338 -0
  88. package/backend/scripts/setup-output.mjs +108 -0
  89. package/backend/scripts/setup.mjs +344 -0
  90. package/backend/scripts/signing-key.mjs +61 -0
  91. package/backend/tsconfig.json +16 -0
  92. package/dist/backlog/folder.js +114 -0
  93. package/dist/bin/dayhand.js +10 -0
  94. package/dist/bin/hlm.js +12 -0
  95. package/dist/completion/cache.js +54 -0
  96. package/dist/completion/engine.js +187 -0
  97. package/dist/completion/scripts.js +82 -0
  98. package/dist/config/aliases.js +25 -0
  99. package/dist/config/env.js +68 -0
  100. package/dist/config/file.js +146 -0
  101. package/dist/config/paths.js +58 -0
  102. package/dist/config/resolve.js +53 -0
  103. package/dist/config/url.js +26 -0
  104. package/dist/core/editor.js +37 -0
  105. package/dist/core/exit.js +59 -0
  106. package/dist/core/io.js +14 -0
  107. package/dist/core/output.js +39 -0
  108. package/dist/core/prompt.js +55 -0
  109. package/dist/core/version.js +15 -0
  110. package/dist/core/words.js +36 -0
  111. package/dist/credentials/file.js +65 -0
  112. package/dist/credentials/index.js +29 -0
  113. package/dist/credentials/keychain.js +44 -0
  114. package/dist/credentials/store.js +39 -0
  115. package/dist/docs.js +116 -0
  116. package/dist/fields.js +33 -0
  117. package/dist/globals.js +16 -0
  118. package/dist/guide.js +36 -0
  119. package/dist/help.js +56 -0
  120. package/dist/i18n/dates.js +84 -0
  121. package/dist/i18n/de.js +240 -0
  122. package/dist/i18n/en.js +243 -0
  123. package/dist/i18n/es.js +240 -0
  124. package/dist/i18n/fr.js +240 -0
  125. package/dist/i18n/index.js +67 -0
  126. package/dist/i18n/ja.js +240 -0
  127. package/dist/i18n/pl.js +245 -0
  128. package/dist/i18n/pt-BR.js +240 -0
  129. package/dist/i18n/zh-Hans.js +240 -0
  130. package/dist/main.js +180 -0
  131. package/dist/man.js +56 -0
  132. package/dist/registry/agents.js +67 -0
  133. package/dist/registry/auth.js +168 -0
  134. package/dist/registry/backlog.js +106 -0
  135. package/dist/registry/calendar.js +103 -0
  136. package/dist/registry/dates.js +33 -0
  137. package/dist/registry/filters.js +98 -0
  138. package/dist/registry/index.js +24 -0
  139. package/dist/registry/integrations.js +245 -0
  140. package/dist/registry/local.js +89 -0
  141. package/dist/registry/model.js +104 -0
  142. package/dist/registry/ops.js +47 -0
  143. package/dist/registry/owner.js +349 -0
  144. package/dist/registry/packaged-backend.js +59 -0
  145. package/dist/registry/parse.js +129 -0
  146. package/dist/registry/server.js +252 -0
  147. package/dist/registry/system.js +220 -0
  148. package/dist/registry/tasks.js +512 -0
  149. package/dist/registry/types.js +1 -0
  150. package/dist/registry/ui.js +51 -0
  151. package/dist/registry/work.js +207 -0
  152. package/dist/render/format.js +146 -0
  153. package/dist/render/model.js +29 -0
  154. package/dist/render/style.js +24 -0
  155. package/dist/render/table.js +46 -0
  156. package/dist/render/views.js +256 -0
  157. package/dist/run.js +133 -0
  158. package/dist/setup.js +26 -0
  159. package/dist/skill.js +83 -0
  160. package/dist/topics.js +121 -0
  161. package/dist/transport/client.js +196 -0
  162. package/dist/ui/agents.js +12 -0
  163. package/dist/ui/api.js +99 -0
  164. package/dist/ui/app.js +62 -0
  165. package/dist/ui/budget.js +42 -0
  166. package/dist/ui/controller.js +368 -0
  167. package/dist/ui/effects.js +166 -0
  168. package/dist/ui/frame.js +206 -0
  169. package/dist/ui/handle.js +271 -0
  170. package/dist/ui/health.js +22 -0
  171. package/dist/ui/keys.js +120 -0
  172. package/dist/ui/line.js +56 -0
  173. package/dist/ui/lists.js +58 -0
  174. package/dist/ui/model.js +25 -0
  175. package/dist/ui/quickadd.js +66 -0
  176. package/dist/ui/rows.js +68 -0
  177. package/dist/ui/run.js +54 -0
  178. package/dist/ui/screens/agents.js +65 -0
  179. package/dist/ui/screens/board.js +41 -0
  180. package/dist/ui/screens/detail.js +142 -0
  181. package/dist/ui/screens/overlay.js +58 -0
  182. package/dist/ui/screens/screen.js +3 -0
  183. package/dist/ui/screens/today.js +60 -0
  184. package/dist/ui/screens/week.js +76 -0
  185. package/dist/ui/state.js +128 -0
  186. package/dist/ui/text.js +70 -0
  187. package/dist/when/calendar.js +95 -0
  188. package/dist/when/parse.js +314 -0
  189. package/man/dayhand.1 +1163 -0
  190. package/man/dh.1 +1 -0
  191. package/package.json +56 -4
@@ -0,0 +1,19 @@
1
+ // The deployment's DAYHAND_* environment variables. Deployments set up before
2
+ // the rename to Dayhand have them as HELM_*: those are still read, with one
3
+ // warning per variable in the deployment's logs, until DAYHAND_* is set
4
+ // (docs/deploy.md#renamed-variables).
5
+
6
+ const warned = new Set<string>();
7
+
8
+ /** DAYHAND_<name>, else the deprecated HELM_<name>; empty values count as unset. */
9
+ export function serverEnv(name: string): string | undefined {
10
+ const value = process.env[`DAYHAND_${name}`];
11
+ if (value) return value;
12
+ const old = process.env[`HELM_${name}`];
13
+ if (!old) return undefined;
14
+ if (!warned.has(name)) {
15
+ warned.add(name);
16
+ console.warn(`HELM_${name} is deprecated; set DAYHAND_${name} to the same value (docs/deploy.md#renamed-variables)`);
17
+ }
18
+ return old;
19
+ }
@@ -0,0 +1,98 @@
1
+ // Export of the whole plan: Markdown to read, JSON as a full copy, iCalendar
2
+ // (lib/ical.ts) for a calendar app.
3
+ import type { ObjectType } from "convex/values";
4
+ import type { Doc } from "../_generated/dataModel";
5
+ import type { QueryCtx } from "../_generated/server";
6
+ import type { args } from "./args";
7
+ import { loadSettings, toTask, type Identity } from "./core";
8
+ import { isOverdue, localDate } from "./dates";
9
+ import { renderCalendar } from "./ical";
10
+ import { toRRule } from "./rrule";
11
+ import type { Reminder } from "./validators";
12
+
13
+ export const EXPORT_VERSION = 1;
14
+
15
+ function taskLine(task: Doc<"tasks">, timezone: string): string {
16
+ const box = task.status === "done" ? "[x]" : "[ ]";
17
+ const when = [task.dueDate, task.dueTime].filter(Boolean).join(" ");
18
+ const parts = [`- ${box}`, when, task.title].filter(Boolean);
19
+ if (task.status === "doing") parts.push("(in progress)");
20
+ if (task.completedAt) parts.push(`(done ${localDate(task.completedAt, timezone)})`);
21
+ if (task.repeat) {
22
+ const from = task.repeat.from === "completion" ? ", counted from completion" : "";
23
+ parts.push(`(repeats ${toRRule(task.repeat, task.dueDate)}${from})`);
24
+ }
25
+ parts.push(...task.labels.map((label) => `#${label.replace(/\s+/g, "-")}`));
26
+ const lines = [parts.join(" ")];
27
+ if (task.resumeNote) lines.push(` - Next step: ${task.resumeNote}`);
28
+ for (const item of task.checklist ?? []) lines.push(` - ${item.done ? "[x]" : "[ ]"} ${item.title}`);
29
+ if (task.reminders) lines.push(` - Reminders: ${task.reminders.length ? task.reminders.map(reminderText).join(", ") : "none"}`);
30
+ if (task.note) lines.push(...task.note.split("\n").map((line) => ` > ${line}`));
31
+ for (const link of task.links) lines.push(` - [${link.title ?? link.url}](${link.url})`);
32
+ return lines.join("\n");
33
+ }
34
+
35
+ function reminderText(reminder: Reminder): string {
36
+ if ("minutesBefore" in reminder) return reminder.minutesBefore === 0 ? "at the due time" : `${reminder.minutesBefore} min before`;
37
+ const days = reminder.daysBefore ?? 0;
38
+ return days === 0 ? `${reminder.time} on the day` : `${reminder.time}, ${days} day${days === 1 ? "" : "s"} before`;
39
+ }
40
+
41
+ function section(title: string, tasks: Doc<"tasks">[], timezone: string): string[] {
42
+ if (tasks.length === 0) return [];
43
+ return [`## ${title}`, "", ...tasks.map((t) => taskLine(t, timezone)), ""];
44
+ }
45
+
46
+ function byDue(a: Doc<"tasks">, b: Doc<"tasks">) {
47
+ const key = (t: Doc<"tasks">) => `${t.dueDate ?? "9999-99-99"} ${t.dueTime ?? "99:99"}`;
48
+ return key(a).localeCompare(key(b)) || a.createdAt - b.createdAt;
49
+ }
50
+
51
+ export function renderMarkdown(tasks: Doc<"tasks">[], timezone: string, at: number): string {
52
+ const today = localDate(at, timezone);
53
+ const live = tasks.filter((t) => t.deletedAt === undefined).sort(byDue);
54
+ const active = live.filter((t) => t.archivedAt === undefined);
55
+ const open = active.filter((t) => t.status !== "done");
56
+ return [
57
+ `# Dayhand plan`,
58
+ "",
59
+ `Exported ${new Date(at).toISOString()} (today is ${today}, ${timezone}).`,
60
+ "",
61
+ ...section("Now", open.filter((t) => t.isNow), timezone),
62
+ ...section("Overdue", open.filter((t) => isOverdue(t, today)), timezone),
63
+ ...section(`Today (${today})`, open.filter((t) => t.dueDate === today), timezone),
64
+ ...section("Upcoming", open.filter((t) => t.dueDate !== undefined && t.dueDate > today), timezone),
65
+ ...section("No date", open.filter((t) => t.dueDate === undefined), timezone),
66
+ ...section("Done", active.filter((t) => t.status === "done"), timezone),
67
+ ...section("Archived", live.filter((t) => t.archivedAt !== undefined), timezone),
68
+ ].join("\n");
69
+ }
70
+
71
+ export async function exportPlan(ctx: QueryCtx, identity: Identity, input: Args) {
72
+ const at = input.at ?? Date.now();
73
+ const settings = await loadSettings(ctx, identity.userId);
74
+ const tasks = await ctx.db
75
+ .query("tasks")
76
+ .withIndex("by_user", (q) => q.eq("userId", identity.userId))
77
+ .collect();
78
+ if (input.format === "markdown") {
79
+ return { format: "markdown" as const, content: renderMarkdown(tasks, settings.timezone, at) };
80
+ }
81
+ if (input.format === "ics") return { format: "ics" as const, content: renderCalendar(tasks, { ...settings, at }) };
82
+ const events = await ctx.db
83
+ .query("taskEvents")
84
+ .withIndex("by_user", (q) => q.eq("userId", identity.userId))
85
+ .collect();
86
+ return {
87
+ format: "json" as const,
88
+ content: {
89
+ version: EXPORT_VERSION,
90
+ exportedAt: at,
91
+ settings,
92
+ tasks: tasks.map(toTask),
93
+ events: events.map(({ _id, _creationTime, userId, ...event }) => event),
94
+ },
95
+ };
96
+ }
97
+
98
+ type Args = ObjectType<(typeof args)["exportPlan"]>;
@@ -0,0 +1,119 @@
1
+ // list_all through MCP: search, filters and pages over the user's tasks.
2
+ // Without `limit` every match is returned, as list_all did before 0.4.0.
3
+ import type { ObjectType } from "convex/values";
4
+ import type { Doc } from "../_generated/dataModel";
5
+ import type { QueryCtx } from "../_generated/server";
6
+ import type { args } from "./args";
7
+ import { isReady } from "./agentWork";
8
+ import { fail, loadSettings, toTask, type Identity } from "./core";
9
+ import { isOverdue, localDate, resolveDate } from "./dates";
10
+
11
+ type Args = ObjectType<(typeof args)["findTasks"]>;
12
+
13
+ export const MAX_PAGE = 200;
14
+ const MAX_QUERY = 200;
15
+
16
+ // Sort key: due day (undated first), timed before untimed, time, creation, id.
17
+ // The cursor is the key of the last task of a page, so pages stay stable
18
+ // when tasks are added or removed between calls.
19
+ type SortKey = [string, number, string, number, string];
20
+
21
+ const sortKey = (t: Doc<"tasks">): SortKey => [t.dueDate ?? "", t.dueTime ? 0 : 1, t.dueTime ?? "", t.createdAt, t._id];
22
+
23
+ function compareKeys(a: SortKey, b: SortKey): number {
24
+ for (let i = 0; i < a.length; i++) {
25
+ if (a[i]! < b[i]!) return -1;
26
+ if (a[i]! > b[i]!) return 1;
27
+ }
28
+ return 0;
29
+ }
30
+
31
+ function encodeCursor(key: SortKey): string {
32
+ return btoa(JSON.stringify(key)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
33
+ }
34
+
35
+ function decodeCursor(cursor: string): SortKey {
36
+ try {
37
+ const key = JSON.parse(atob(cursor.replace(/-/g, "+").replace(/_/g, "/")));
38
+ const types = ["string", "number", "string", "number", "string"];
39
+ if (Array.isArray(key) && key.length === types.length && key.every((part, i) => typeof part === types[i])) {
40
+ return key as SortKey;
41
+ }
42
+ } catch {
43
+ // reported below
44
+ }
45
+ return fail("invalid_argument", "cursor is not valid; start again without it");
46
+ }
47
+
48
+ function checkLimit(limit: number | undefined): number | undefined {
49
+ if (limit === undefined) return undefined;
50
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE) {
51
+ fail("invalid_argument", `limit must be a whole number from 1 to ${MAX_PAGE}`);
52
+ }
53
+ return limit;
54
+ }
55
+
56
+ function checkDay(field: "dueBefore" | "dueAfter", value: string | undefined, today: string): string | undefined {
57
+ if (value === undefined) return undefined;
58
+ const day = resolveDate(value, today);
59
+ if (!day) fail("invalid_argument", `${field} must be YYYY-MM-DD, "today" or "tomorrow", got "${value}"`);
60
+ return day;
61
+ }
62
+
63
+ function checkQuery(query: string | undefined): string | undefined {
64
+ if (query === undefined) return undefined;
65
+ if (query.length > MAX_QUERY) fail("invalid_argument", `query is longer than ${MAX_QUERY} characters`);
66
+ const needle = query.trim().toLowerCase();
67
+ return needle === "" ? undefined : needle;
68
+ }
69
+
70
+ /**
71
+ * A predicate for the filters; `status`, when given, decides alone whether done
72
+ * tasks count. `ready` looks the dependencies up among the user's tasks.
73
+ */
74
+ function matcher(input: Args, today: string, byId: Map<string, Doc<"tasks">>) {
75
+ const wanted = (input.labels ?? []).map((l) => l.trim()).filter((l) => l !== "");
76
+ const needle = checkQuery(input.query);
77
+ const before = checkDay("dueBefore", input.dueBefore, today);
78
+ const after = checkDay("dueAfter", input.dueAfter, today);
79
+ const statuses = input.status;
80
+ // Proposals (since 0.11.0) wait in the inbox: only `proposed: true` lists them.
81
+ const proposed = input.proposed ?? false;
82
+ return (t: Doc<"tasks">) =>
83
+ t.deletedAt === undefined &&
84
+ (t.proposedAt !== undefined) === proposed &&
85
+ (input.includeArchived || t.archivedAt === undefined) &&
86
+ (statuses ? statuses.includes(t.status) : input.includeDone !== false || t.status !== "done") &&
87
+ wanted.every((label) => t.labels.includes(label)) &&
88
+ (input.overdue === undefined || isOverdue(t, today) === input.overdue) &&
89
+ (input.ready === undefined || isReady(t, byId) === input.ready) &&
90
+ (before === undefined || (t.dueDate !== undefined && t.dueDate < before)) &&
91
+ (after === undefined || (t.dueDate !== undefined && t.dueDate > after)) &&
92
+ (needle === undefined ||
93
+ [t.title, t.note, t.resumeNote, ...t.labels].some((text) => text?.toLowerCase().includes(needle)));
94
+ }
95
+
96
+ /** One page of matching tasks, oldest due first; `total` counts every match. */
97
+ export async function findTasks(ctx: QueryCtx, identity: Identity, input: Args) {
98
+ const at = input.at ?? Date.now();
99
+ const today = localDate(at, (await loadSettings(ctx, identity.userId)).timezone);
100
+ const limit = checkLimit(input.limit);
101
+ const after = input.cursor === undefined ? undefined : decodeCursor(input.cursor);
102
+ const tasks = await ctx.db
103
+ .query("tasks")
104
+ .withIndex("by_user", (q) => q.eq("userId", identity.userId))
105
+ .collect();
106
+ const matches = matcher(input, today, new Map(tasks.map((t) => [t._id as string, t])));
107
+ const found = tasks
108
+ .filter(matches)
109
+ .map((t) => ({ task: t, key: sortKey(t) }))
110
+ .sort((a, b) => compareKeys(a.key, b.key));
111
+ const rest = after === undefined ? found : found.filter((f) => compareKeys(f.key, after) > 0);
112
+ const page = limit === undefined ? rest : rest.slice(0, limit);
113
+ const more = page.length < rest.length;
114
+ return {
115
+ result: page.map(({ task }) => ({ ...toTask(task), overdue: isOverdue(task, today) })),
116
+ nextCursor: more ? encodeCursor(page[page.length - 1]!.key) : null,
117
+ total: found.length,
118
+ };
119
+ }
@@ -0,0 +1,165 @@
1
+ // The YAML header of a Markdown file ("frontmatter", between two "---" lines),
2
+ // as Backlog.md task files use it (docs/backlog-md.md): keys with plain or
3
+ // quoted scalars, flow lists ([a, "b"]) and block lists ("- a"), and block
4
+ // text (| and >). Not a full YAML reader: nested maps, anchors and tags are
5
+ // not part of that header and are read as text. Pure functions.
6
+
7
+ export type FrontValue = string | string[] | null;
8
+ export type Front = Record<string, FrontValue>;
9
+
10
+ export class FrontmatterError extends Error {}
11
+
12
+ const KEY = /^([A-Za-z_][\w.-]*)\s*:(?:\s+(.*))?$/;
13
+ const LIST_ITEM = /^\s*-(?:\s+(.*))?$/;
14
+
15
+ /** The header and the text after it; a file without a header has `front` null. */
16
+ export function splitFrontmatter(content: string): { front: string | null; body: string } {
17
+ const text = content.replace(/^/, "").replace(/\r\n?/g, "\n");
18
+ if (!text.startsWith("---\n")) return { front: null, body: text };
19
+ const end = text.indexOf("\n---", 3);
20
+ if (end === -1) throw new FrontmatterError("The YAML header has no closing --- line");
21
+ const after = text.indexOf("\n", end + 4);
22
+ return { front: text.slice(4, end + 1), body: after === -1 ? "" : text.slice(after + 1) };
23
+ }
24
+
25
+ /** Removes a trailing comment (" # …") outside quotes. */
26
+ function stripComment(value: string): string {
27
+ let quote: string | null = null;
28
+ for (let i = 0; i < value.length; i++) {
29
+ const c = value[i]!;
30
+ if (quote) {
31
+ if (quote === "'" && c === "'" && value[i + 1] === "'") i++;
32
+ else if (quote === '"' && c === "\\") i++;
33
+ else if (c === quote) quote = null;
34
+ } else if (c === '"' || c === "'") {
35
+ if (i === 0 || /[\s,[]/.test(value[i - 1]!)) quote = c;
36
+ } else if (c === "#" && (i === 0 || /\s/.test(value[i - 1]!))) {
37
+ return value.slice(0, i).trimEnd();
38
+ }
39
+ }
40
+ return value.trimEnd();
41
+ }
42
+
43
+ const ESCAPES: Record<string, string> = { n: "\n", t: "\t", r: "\r", '"': '"', "\\": "\\", "/": "/", "0": "\0" };
44
+
45
+ /** One scalar: quoted ('it''s', "a \"b\"") or plain; ~ and null are no value. */
46
+ function scalar(raw: string): string | null {
47
+ const value = raw.trim();
48
+ if (value === "" || value === "~" || value === "null" || value === "Null" || value === "NULL") return null;
49
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) return value.slice(1, -1).replace(/''/g, "'");
50
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
51
+ return value.slice(1, -1).replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, e: string) => (e.length === 5 ? String.fromCharCode(parseInt(e.slice(1), 16)) : (ESCAPES[e] ?? e)));
52
+ }
53
+ return value;
54
+ }
55
+
56
+ /** The items of a flow list "[a, 'b, c', \"d\"]". */
57
+ function flowList(raw: string): string[] {
58
+ const inner = raw.trim().slice(1, -1);
59
+ const items: string[] = [];
60
+ let current = "";
61
+ let quote: string | null = null;
62
+ for (let i = 0; i < inner.length; i++) {
63
+ const c = inner[i]!;
64
+ if (quote) {
65
+ current += c;
66
+ if (c === quote) {
67
+ if (quote === "'" && inner[i + 1] === "'") current += inner[++i];
68
+ else quote = null;
69
+ } else if (quote === '"' && c === "\\" && i + 1 < inner.length) {
70
+ current += inner[++i];
71
+ }
72
+ } else if ((c === "'" || c === '"') && current.trim() === "") {
73
+ quote = c;
74
+ current += c;
75
+ } else if (c === ",") {
76
+ items.push(current);
77
+ current = "";
78
+ } else current += c;
79
+ }
80
+ if (current.trim() !== "" || items.length > 0) items.push(current);
81
+ return items.map(scalar).filter((item): item is string => item !== null);
82
+ }
83
+
84
+ const indentOf = (line: string) => line.length - line.trimStart().length;
85
+
86
+ /** Block text after "key: |" or "key: >": the more indented lines that follow. */
87
+ function blockText(lines: string[], start: number, folded: boolean): { value: string; next: number } {
88
+ let i = start;
89
+ const taken: string[] = [];
90
+ while (i < lines.length && (lines[i]!.trim() === "" || indentOf(lines[i]!) > 0)) taken.push(lines[i++]!);
91
+ while (taken.length && taken[taken.length - 1]!.trim() === "") taken.pop();
92
+ const indent = Math.min(...taken.filter((l) => l.trim() !== "").map(indentOf));
93
+ const body = taken.map((l) => l.slice(Number.isFinite(indent) ? indent : 0));
94
+ const value = folded ? body.join("\n").replace(/([^\n])\n(?=[^\n])/g, "$1 ") : body.join("\n");
95
+ return { value, next: i };
96
+ }
97
+
98
+ /** Reads the header's keys. A key given twice keeps its last value, as YAML readers do. */
99
+ export function parseFrontmatter(front: string): Front {
100
+ const lines = front.split("\n");
101
+ const result: Front = {};
102
+ let i = 0;
103
+ while (i < lines.length) {
104
+ const line = lines[i]!;
105
+ if (line.trim() === "" || line.trimStart().startsWith("#")) {
106
+ i++;
107
+ continue;
108
+ }
109
+ const match = indentOf(line) === 0 ? KEY.exec(line) : null;
110
+ if (!match) throw new FrontmatterError(`Line ${i + 1} of the YAML header is not "key: value": ${line.trim().slice(0, 80)}`);
111
+ const key = match[1]!;
112
+ const raw = stripComment(match[2] ?? "");
113
+ i++;
114
+ if (raw === "|" || raw === "|-" || raw === ">" || raw === ">-") {
115
+ const block = blockText(lines, i, raw.startsWith(">"));
116
+ result[key] = block.value;
117
+ i = block.next;
118
+ } else if (raw.startsWith("[") && raw.endsWith("]")) {
119
+ result[key] = flowList(raw);
120
+ } else if (raw === "") {
121
+ // A block list follows, or the key has no value.
122
+ const items: string[] = [];
123
+ while (i < lines.length && (lines[i]!.trim() === "" || LIST_ITEM.test(lines[i]!))) {
124
+ const item = LIST_ITEM.exec(lines[i]!);
125
+ if (item) {
126
+ const value = scalar(stripComment(item[1] ?? ""));
127
+ if (value !== null) items.push(value);
128
+ }
129
+ i++;
130
+ }
131
+ result[key] = items.length > 0 ? items : null;
132
+ } else {
133
+ // A plain scalar may continue on more indented lines.
134
+ let value = raw;
135
+ while (i < lines.length && lines[i]!.trim() !== "" && indentOf(lines[i]!) > 0 && !LIST_ITEM.test(lines[i]!)) value += ` ${lines[i++]!.trim()}`;
136
+ result[key] = scalar(value);
137
+ }
138
+ }
139
+ return result;
140
+ }
141
+
142
+ // ---- Writing ----
143
+
144
+ const PLAIN = /^[A-Za-z0-9_/.()+-][^:#\n]*$/;
145
+ const RESERVED = /^(?:true|false|yes|no|on|off|null|~|[-+]?(?:\d[\d_]*)?(?:\.\d+)?(?:e[-+]?\d+)?|0x[0-9a-f]+)$/i;
146
+
147
+ /** A scalar as YAML: plain when that reads back as the same text, else single-quoted. */
148
+ export function yamlScalar(value: string): string {
149
+ if (value !== "" && PLAIN.test(value) && value === value.trim() && !RESERVED.test(value) && !value.startsWith("- ")) return value;
150
+ return `'${value.replace(/'/g, "''").replace(/\n/g, " ")}'`;
151
+ }
152
+
153
+ /** The header lines for the keys in order; a list is written as a block list, an empty one as []. */
154
+ export function writeFrontmatter(entries: [string, string | string[] | undefined][]): string {
155
+ const lines: string[] = ["---"];
156
+ for (const [key, value] of entries) {
157
+ if (value === undefined) continue;
158
+ if (Array.isArray(value)) {
159
+ if (value.length === 0) lines.push(`${key}: []`);
160
+ else lines.push(`${key}:`, ...value.map((item) => ` - ${yamlScalar(item)}`));
161
+ } else lines.push(`${key}: ${yamlScalar(value)}`);
162
+ }
163
+ lines.push("---");
164
+ return lines.join("\n");
165
+ }