toolcraft 0.0.50 → 0.0.52

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 (47) hide show
  1. package/node_modules/@poe-code/agent-defs/README.md +35 -0
  2. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/mock.js +9 -2
  3. package/node_modules/@poe-code/agent-human-in-loop/dist/providers/osascript-script.js +3 -1
  4. package/node_modules/@poe-code/agent-human-in-loop/dist/request-approval.js +44 -2
  5. package/node_modules/@poe-code/agent-human-in-loop/package.json +0 -1
  6. package/node_modules/@poe-code/agent-mcp-config/README.md +54 -0
  7. package/node_modules/@poe-code/agent-mcp-config/package.json +0 -2
  8. package/node_modules/@poe-code/config-mutations/README.md +55 -0
  9. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.d.ts +1 -0
  10. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +65 -0
  11. package/node_modules/@poe-code/config-mutations/dist/execution/run-mutations.js +4 -11
  12. package/node_modules/@poe-code/config-mutations/package.json +0 -1
  13. package/node_modules/@poe-code/frontmatter/dist/fences.d.ts +17 -0
  14. package/node_modules/@poe-code/frontmatter/dist/fences.js +33 -5
  15. package/node_modules/@poe-code/frontmatter/dist/parse.js +86 -14
  16. package/node_modules/@poe-code/frontmatter/dist/stringify.js +13 -0
  17. package/node_modules/@poe-code/process-runner/dist/docker/args.js +14 -1
  18. package/node_modules/@poe-code/process-runner/dist/docker/build-context.d.ts +5 -0
  19. package/node_modules/@poe-code/process-runner/dist/docker/build-context.js +37 -0
  20. package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +29 -29
  21. package/node_modules/@poe-code/process-runner/dist/index.d.ts +1 -0
  22. package/node_modules/@poe-code/process-runner/dist/index.js +1 -0
  23. package/node_modules/@poe-code/process-runner/dist/workspace-transfer.js +49 -3
  24. package/node_modules/@poe-code/process-runner/package.json +8 -1
  25. package/node_modules/@poe-code/task-list/dist/backends/gh-issues-sync.js +7 -0
  26. package/node_modules/@poe-code/task-list/dist/backends/gh-issues.js +34 -7
  27. package/node_modules/@poe-code/task-list/dist/backends/markdown-dir.js +75 -19
  28. package/node_modules/@poe-code/task-list/dist/backends/utils.d.ts +2 -0
  29. package/node_modules/@poe-code/task-list/dist/backends/utils.js +23 -2
  30. package/node_modules/@poe-code/task-list/dist/backends/yaml-file.js +16 -12
  31. package/node_modules/@poe-code/task-list/dist/state-machine.js +9 -0
  32. package/node_modules/@poe-code/task-list/package.json +0 -1
  33. package/node_modules/auth-store/dist/create-secret-store.js +4 -3
  34. package/node_modules/auth-store/dist/encrypted-file-store.js +49 -2
  35. package/node_modules/auth-store/dist/keychain-store.js +11 -4
  36. package/node_modules/auth-store/package.json +0 -1
  37. package/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +69 -12
  38. package/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +100 -68
  39. package/node_modules/mcp-oauth/dist/client/loopback-authorization.js +19 -18
  40. package/node_modules/mcp-oauth/dist/client/token-endpoint.js +37 -31
  41. package/node_modules/mcp-oauth/package.json +0 -1
  42. package/node_modules/tiny-mcp-client/dist/internal.js +96 -10
  43. package/node_modules/tiny-mcp-client/package.json +0 -3
  44. package/node_modules/tiny-mcp-client/src/internal.ts +120 -18
  45. package/node_modules/tiny-mcp-client/src/transports.test.ts +231 -2
  46. package/node_modules/toolcraft-design/package.json +0 -1
  47. package/package.json +3 -2
@@ -0,0 +1,35 @@
1
+ # @poe-code/agent-defs
2
+
3
+ Shared catalog of supported coding-agent definitions.
4
+
5
+ This package owns the declarative agent metadata used by CLI, SDK, spawn,
6
+ configuration, and UI surfaces: stable ids, labels, aliases, config paths,
7
+ binary names, API-shape support, OTEL capture wiring, and brand colors.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { allAgents, normalizeAgentId, parseAgentSpecifier } from "@poe-code/agent-defs";
13
+
14
+ const specifier = parseAgentSpecifier("claude:sonnet");
15
+ const normalized = normalizeAgentId(specifier.agent);
16
+ const codex = allAgents.find((agent) => agent.id === "codex");
17
+ ```
18
+
19
+ ## Public API
20
+
21
+ - `allAgents`: frozen list of built-in `AgentDefinition` records.
22
+ - `resolveAgentId(input)`: resolves ids, names, and aliases to a stable agent id.
23
+ - `parseAgentSpecifier(input)`: parses `agent` or `agent:model` input.
24
+ - `formatAgentSpecifier(specifier)`: formats an agent specifier.
25
+ - `normalizeAgentId(input)`: normalizes the agent part through the registry.
26
+ - Agent definition exports such as `codexAgent`, `claudeCodeAgent`, and `geminiCliAgent`.
27
+
28
+ ## Config Options
29
+
30
+ This package does not load external config. Agent definitions are declared in
31
+ source and exposed as immutable data.
32
+
33
+ ## Environment Variables
34
+
35
+ This package does not read or expose environment variables.
@@ -3,9 +3,16 @@ export function mockProvider(answer) {
3
3
  id: "mock",
4
4
  async requestApproval(_request) {
5
5
  if (typeof answer === "function") {
6
- return await answer();
6
+ return cloneApprovalResult(await answer());
7
7
  }
8
- return answer;
8
+ return cloneApprovalResult(answer);
9
9
  },
10
10
  };
11
11
  }
12
+ function cloneApprovalResult(result) {
13
+ return result.outcome === "approved"
14
+ ? { outcome: "approved" }
15
+ : result.reason === undefined
16
+ ? { outcome: "declined" }
17
+ : { outcome: "declined", reason: result.reason };
18
+ }
@@ -24,7 +24,9 @@ export function parseStdout(out) {
24
24
  ? out.slice(0, -2)
25
25
  : out.endsWith("\n")
26
26
  ? out.slice(0, -1)
27
- : out;
27
+ : out.endsWith("\r")
28
+ ? out.slice(0, -1)
29
+ : out;
28
30
  switch (value) {
29
31
  case "Approve":
30
32
  case "APPROVED":
@@ -1,4 +1,46 @@
1
- export function requestApproval(args) {
1
+ export async function requestApproval(args) {
2
2
  const { provider, ...request } = args;
3
- return provider.requestApproval(request);
3
+ return normalizeApprovalResult(await provider.requestApproval(normalizeApprovalRequest(request)));
4
+ }
5
+ function normalizeApprovalRequest(request) {
6
+ if (typeof request.message !== "string" || request.message.trim().length === 0) {
7
+ throw new Error("Approval request message must not be blank");
8
+ }
9
+ if (request.declineInputPrompt !== undefined &&
10
+ typeof request.declineInputPrompt !== "string") {
11
+ throw new Error("Approval request declineInputPrompt must be a string");
12
+ }
13
+ return request.declineInputPrompt === undefined
14
+ ? { message: request.message }
15
+ : {
16
+ message: request.message,
17
+ declineInputPrompt: request.declineInputPrompt,
18
+ };
19
+ }
20
+ function normalizeApprovalResult(result) {
21
+ if (!isObjectRecord(result)) {
22
+ throw new Error("Approval provider returned an invalid result");
23
+ }
24
+ const outcome = getOwnEntry(result, "outcome");
25
+ if (outcome === "approved") {
26
+ return { outcome: "approved" };
27
+ }
28
+ if (outcome === "declined") {
29
+ const reason = getOwnEntry(result, "reason");
30
+ if (reason === undefined) {
31
+ return { outcome: "declined" };
32
+ }
33
+ if (typeof reason === "string") {
34
+ return { outcome: "declined", reason };
35
+ }
36
+ }
37
+ throw new Error("Approval provider returned an invalid result");
38
+ }
39
+ function isObjectRecord(value) {
40
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ }
42
+ function getOwnEntry(record, key) {
43
+ return Object.prototype.hasOwnProperty.call(record, key)
44
+ ? record[key]
45
+ : undefined;
4
46
  }
@@ -20,6 +20,5 @@
20
20
  "files": [
21
21
  "dist"
22
22
  ],
23
- "dependencies": {},
24
23
  "devDependencies": {}
25
24
  }
@@ -0,0 +1,54 @@
1
+ # @poe-code/agent-mcp-config
2
+
3
+ MCP configuration writer for supported local coding agents.
4
+
5
+ This package maps a normalized MCP server entry to each agent's native config
6
+ file, config key, file format, and shape. It is used by configure and
7
+ unconfigure flows so caller code can stay declarative.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { configure, unconfigure, supportedAgents } from "@poe-code/agent-mcp-config";
13
+
14
+ await configure(
15
+ "codex",
16
+ {
17
+ name: "workspace",
18
+ config: { transport: "stdio", command: "node", args: ["./mcp.js"] }
19
+ },
20
+ { fs, homeDir: "/Users/me", platform: "darwin" }
21
+ );
22
+
23
+ console.log(supportedAgents);
24
+ ```
25
+
26
+ ## Public API
27
+
28
+ - `configure(agentId, server, options)`: adds or replaces a matching MCP server entry.
29
+ - `unconfigure(agentId, server, options)`: removes an MCP server entry.
30
+ - `supportedAgents`: agent ids with config-file MCP support.
31
+ - `isSupported(agentId)`: returns whether an agent has MCP config support.
32
+ - `resolveAgentSupport(input)`: resolves aliases and returns support metadata.
33
+ - `UnsupportedAgentError`: thrown for unsupported known agents.
34
+
35
+ ## Config Options
36
+
37
+ `ApplyOptions` controls how mutations are applied:
38
+
39
+ | Option | Type | Description |
40
+ | ------ | ---- | ----------- |
41
+ | `fs` | `FileSystem` | Filesystem adapter from `@poe-code/config-mutations`. |
42
+ | `homeDir` | `string` | Home directory used to resolve `~` in agent config paths. |
43
+ | `platform` | `"darwin" \| "linux" \| "win32"` | Selects platform-specific config paths. |
44
+ | `dryRun` | `boolean` | Computes mutations without writing files. |
45
+ | `observers` | `MutationObservers` | Receives mutation lifecycle events. |
46
+
47
+ The package's built-in agent config table declares `configFile`, `configKey`,
48
+ `format`, `shape`, and optional `mcpOutputFormat` per supported agent.
49
+
50
+ ## Environment Variables
51
+
52
+ This package does not read or expose environment variables. Server-specific
53
+ environment variables may be written into target agent config files when they
54
+ are present in an `McpStdioServer` entry.
@@ -18,8 +18,6 @@
18
18
  "dist"
19
19
  ],
20
20
  "dependencies": {
21
- "@poe-code/agent-defs": "*",
22
- "@poe-code/config-mutations": "*",
23
21
  "yaml": "^2.8.3"
24
22
  }
25
23
  }
@@ -0,0 +1,55 @@
1
+ # @poe-code/config-mutations
2
+
3
+ Composable file and config mutation engine.
4
+
5
+ This package applies ordered filesystem, JSON, TOML, YAML, and template
6
+ mutations with dry-run support and observer hooks. Callers inject the filesystem
7
+ so tests can use in-memory adapters and production flows can use
8
+ `node:fs/promises`.
9
+
10
+ ## Usage
11
+
12
+ ```ts
13
+ import { configMutation, fileMutation, runMutations } from "@poe-code/config-mutations";
14
+
15
+ await runMutations(
16
+ [
17
+ fileMutation.ensureDirectory({ path: "~/.config/example" }),
18
+ configMutation.merge({
19
+ target: "~/.config/example/config.json",
20
+ format: "json",
21
+ value: { enabled: true }
22
+ })
23
+ ],
24
+ { fs, homeDir: "/Users/me" }
25
+ );
26
+ ```
27
+
28
+ ## Public API
29
+
30
+ - `runMutations(mutations, context)`: applies mutations in order.
31
+ - `configMutation`: builders for config merge, prune, and transform mutations.
32
+ - `fileMutation`: builders for directory, file, backup, restore, and mode mutations.
33
+ - `templateMutation`: builders for template writes and template-backed config merges.
34
+ - `renderTemplate(template, variables)`: renders template variables.
35
+ - Filesystem helpers: `isNotFound`, `readFileIfExists`, `pathExists`, and `createTimestamp`.
36
+
37
+ ## Config Options
38
+
39
+ `MutationContext` controls execution:
40
+
41
+ | Option | Type | Description |
42
+ | ------ | ---- | ----------- |
43
+ | `fs` | `FileSystem` | Required filesystem adapter. |
44
+ | `homeDir` | `string` | Required home directory for `~` expansion. |
45
+ | `dryRun` | `boolean` | Reports changes without writing them. |
46
+ | `observers` | `MutationObservers` | Receives mutation lifecycle events. |
47
+ | `templates` | `TemplateLoader` | Loads templates referenced by template mutations. |
48
+ | `pathMapper` | `PathMapper` | Redirects target directories for isolated config flows. |
49
+
50
+ Mutation builders also expose per-mutation options such as target path, format,
51
+ label, force removal, backup behavior, template id, and transform callbacks.
52
+
53
+ ## Environment Variables
54
+
55
+ This package does not read or expose environment variables.
@@ -1,4 +1,5 @@
1
1
  import type { Mutation, MutationContext, MutationOutcome, MutationDetails, MutationOptions } from "../types.js";
2
+ export declare function resolveMutationDetails(mutation: Mutation, context: MutationContext, options: MutationOptions): MutationDetails;
2
3
  export declare function applyMutation(mutation: Mutation, context: MutationContext, options: MutationOptions): Promise<{
3
4
  outcome: MutationOutcome;
4
5
  details: MutationDetails;
@@ -126,6 +126,58 @@ function describeMutation(kind, targetPath) {
126
126
  return "Operation";
127
127
  }
128
128
  }
129
+ function mutationTargetPath(mutation, options) {
130
+ switch (mutation.kind) {
131
+ case "ensureDirectory":
132
+ case "removeDirectory":
133
+ return resolveValue(mutation.path, options);
134
+ case "removeFile":
135
+ case "chmod":
136
+ case "backup":
137
+ case "restoreBackup":
138
+ case "configMerge":
139
+ case "configPrune":
140
+ case "configTransform":
141
+ case "templateWrite":
142
+ case "templateMergeToml":
143
+ case "templateMergeJson":
144
+ return resolveValue(mutation.target, options);
145
+ default:
146
+ return undefined;
147
+ }
148
+ }
149
+ export function resolveMutationDetails(mutation, context, options) {
150
+ try {
151
+ const rawTarget = mutationTargetPath(mutation, options);
152
+ if (rawTarget === undefined) {
153
+ return {
154
+ kind: mutation.kind,
155
+ label: mutation.label ?? mutation.kind
156
+ };
157
+ }
158
+ try {
159
+ const targetPath = resolvePath(rawTarget, context.homeDir, context.pathMapper);
160
+ return {
161
+ kind: mutation.kind,
162
+ label: mutation.label ?? describeMutation(mutation.kind, targetPath),
163
+ targetPath
164
+ };
165
+ }
166
+ catch {
167
+ return {
168
+ kind: mutation.kind,
169
+ label: mutation.label ?? describeMutation(mutation.kind, rawTarget),
170
+ targetPath: undefined
171
+ };
172
+ }
173
+ }
174
+ catch {
175
+ return {
176
+ kind: mutation.kind,
177
+ label: mutation.label ?? mutation.kind
178
+ };
179
+ }
180
+ }
129
181
  function pruneKeysByPrefix(table, prefix) {
130
182
  const result = {};
131
183
  for (const [key, value] of Object.entries(table)) {
@@ -223,6 +275,7 @@ async function applyEnsureDirectory(mutation, context, options) {
223
275
  label: mutation.label ?? describeMutation(mutation.kind, targetPath),
224
276
  targetPath
225
277
  };
278
+ await assertRegularWriteTarget(context, targetPath);
226
279
  const existed = await pathExists(context.fs, targetPath);
227
280
  if (!context.dryRun) {
228
281
  await context.fs.mkdir(targetPath, { recursive: true });
@@ -342,6 +395,7 @@ async function applyChmod(mutation, context, options) {
342
395
  };
343
396
  }
344
397
  try {
398
+ await assertRegularWriteTarget(context, targetPath);
345
399
  const stat = await context.fs.stat(targetPath);
346
400
  const currentMode = typeof stat.mode === "number" ? stat.mode & 0o777 : null;
347
401
  if (currentMode === mutation.mode) {
@@ -376,6 +430,7 @@ async function applyBackup(mutation, context, options) {
376
430
  label: mutation.label ?? describeMutation(mutation.kind, targetPath),
377
431
  targetPath
378
432
  };
433
+ await assertRegularWriteTarget(context, targetPath);
379
434
  if (mutation.once && (await findLatestGeneratedBackup(context.fs, targetPath)) !== null) {
380
435
  return {
381
436
  outcome: { changed: false, effect: "none", detail: "noop" },
@@ -524,6 +579,9 @@ async function applyConfigMerge(mutation, context, options) {
524
579
  preserveContent = null;
525
580
  }
526
581
  const value = resolveValue(mutation.value, options);
582
+ if (!isConfigObject(value)) {
583
+ throw new Error(`configMerge value must be an object for "${rawPath}".`);
584
+ }
527
585
  // Keep prefix pruning on the same proto-safe object-write path as normal merges.
528
586
  let merged;
529
587
  if (mutation.pruneByPrefix) {
@@ -661,6 +719,13 @@ async function applyConfigTransform(mutation, context, options) {
661
719
  };
662
720
  }
663
721
  const serialized = serializeConfigUpdate(format, preserveContent, current, transformed);
722
+ const serializedChanged = serialized !== rawContent;
723
+ if (!serializedChanged) {
724
+ return {
725
+ outcome: { changed: false, effect: "none", detail: "noop" },
726
+ details
727
+ };
728
+ }
664
729
  if (!context.dryRun) {
665
730
  await writeAtomically(context, targetPath, serialized);
666
731
  }
@@ -1,4 +1,4 @@
1
- import { applyMutation } from "./apply-mutation.js";
1
+ import { applyMutation, resolveMutationDetails } from "./apply-mutation.js";
2
2
  /**
3
3
  * Execute an array of mutations in order.
4
4
  *
@@ -21,12 +21,9 @@ export async function runMutations(mutations, context, options) {
21
21
  };
22
22
  }
23
23
  async function executeMutation(mutation, context, options) {
24
+ const pendingDetails = resolveMutationDetails(mutation, context, options);
24
25
  // Call onStart observer
25
- context.observers?.onStart?.({
26
- kind: mutation.kind,
27
- label: mutation.label ?? mutation.kind,
28
- targetPath: undefined // Will be resolved during apply
29
- });
26
+ context.observers?.onStart?.(pendingDetails);
30
27
  try {
31
28
  const { outcome, details } = await applyMutation(mutation, context, options);
32
29
  // Call onComplete observer
@@ -35,11 +32,7 @@ async function executeMutation(mutation, context, options) {
35
32
  }
36
33
  catch (error) {
37
34
  // Call onError observer
38
- context.observers?.onError?.({
39
- kind: mutation.kind,
40
- label: mutation.label ?? mutation.kind,
41
- targetPath: undefined
42
- }, error);
35
+ context.observers?.onError?.(pendingDetails, error);
43
36
  // Re-throw the error
44
37
  throw error;
45
38
  }
@@ -22,7 +22,6 @@
22
22
  "dist"
23
23
  ],
24
24
  "dependencies": {
25
- "toolcraft-design": "*",
26
25
  "jsonc-parser": "^3.3.1",
27
26
  "smol-toml": "^1.3.0",
28
27
  "yaml": "^2.8.1"
@@ -1,8 +1,25 @@
1
1
  export type FrontmatterBlock = {
2
2
  raw: string;
3
3
  body: string;
4
+ rawStart: number;
5
+ rawEnd: number;
4
6
  };
5
7
  export type SplitFrontmatterResult = FrontmatterBlock | {
6
8
  body: string;
7
9
  };
10
+ export type InspectFrontmatterResult = (FrontmatterBlock & {
11
+ kind: "frontmatter";
12
+ }) | {
13
+ kind: "missing-closing-fence";
14
+ raw: string;
15
+ rawStart: number;
16
+ rawEnd: number;
17
+ body: "";
18
+ message: string;
19
+ position: number;
20
+ } | {
21
+ kind: "body";
22
+ body: string;
23
+ };
8
24
  export declare function splitFrontmatterBlock(source: string): SplitFrontmatterResult;
25
+ export declare function inspectFrontmatterBlock(source: string): InspectFrontmatterResult;
@@ -1,15 +1,43 @@
1
+ const MISSING_END_DELIMITER_MESSAGE = "Missing YAML frontmatter end delimiter (---).";
1
2
  export function splitFrontmatterBlock(source) {
3
+ const inspected = inspectFrontmatterBlock(source);
4
+ if (inspected.kind === "body") {
5
+ return { body: inspected.body };
6
+ }
7
+ if (inspected.kind === "missing-closing-fence") {
8
+ throw new Error(inspected.message);
9
+ }
10
+ return {
11
+ raw: inspected.raw,
12
+ rawStart: inspected.rawStart,
13
+ rawEnd: inspected.rawEnd,
14
+ body: inspected.body
15
+ };
16
+ }
17
+ export function inspectFrontmatterBlock(source) {
2
18
  const content = source.startsWith("\uFEFF") ? source.slice(1) : source;
19
+ const sourceOffset = source.length - content.length;
3
20
  const opening = readOpeningFence(content);
4
21
  if (opening === undefined) {
5
- return { body: source };
22
+ return { kind: "body", body: source };
6
23
  }
7
24
  const closing = findClosingFence(content, opening.next);
8
25
  if (closing === undefined) {
9
- throw new Error("Missing YAML frontmatter end delimiter (---).");
26
+ return {
27
+ kind: "missing-closing-fence",
28
+ raw: content.slice(opening.next),
29
+ rawStart: sourceOffset + opening.next,
30
+ rawEnd: source.length,
31
+ body: "",
32
+ message: MISSING_END_DELIMITER_MESSAGE,
33
+ position: source.length
34
+ };
10
35
  }
11
36
  return {
37
+ kind: "frontmatter",
12
38
  raw: content.slice(opening.next, closing.index),
39
+ rawStart: sourceOffset + opening.next,
40
+ rawEnd: sourceOffset + closing.index,
13
41
  body: content.slice(closing.end + closing.lineBreakLength)
14
42
  };
15
43
  }
@@ -18,7 +46,7 @@ function readOpeningFence(source) {
18
46
  return undefined;
19
47
  }
20
48
  const lineEnd = findLineEnd(source, 0);
21
- if (lineEnd.lineBreakLength === 0 || source.slice(0, lineEnd.index) !== "---") {
49
+ if (lineEnd.lineBreakLength === 0 || !isFenceLine(source.slice(0, lineEnd.index))) {
22
50
  return undefined;
23
51
  }
24
52
  return { next: lineEnd.index + lineEnd.lineBreakLength };
@@ -27,7 +55,7 @@ function findClosingFence(source, start) {
27
55
  let lineStart = start;
28
56
  while (lineStart <= source.length) {
29
57
  const lineEnd = findLineEnd(source, lineStart);
30
- if (isClosingFenceLine(source.slice(lineStart, lineEnd.index))) {
58
+ if (isFenceLine(source.slice(lineStart, lineEnd.index))) {
31
59
  return {
32
60
  index: lineStart,
33
61
  end: lineEnd.index,
@@ -41,7 +69,7 @@ function findClosingFence(source, start) {
41
69
  }
42
70
  return undefined;
43
71
  }
44
- function isClosingFenceLine(line) {
72
+ function isFenceLine(line) {
45
73
  if (!line.startsWith("---")) {
46
74
  return false;
47
75
  }
@@ -1,5 +1,5 @@
1
1
  import { LineCounter, parse, parseDocument } from "yaml";
2
- import { splitFrontmatterBlock } from "./fences.js";
2
+ import { inspectFrontmatterBlock, splitFrontmatterBlock } from "./fences.js";
3
3
  export class FrontmatterParseError extends Error {
4
4
  constructor(message) {
5
5
  super(message);
@@ -20,9 +20,9 @@ export function parseFrontmatter(source, options = {}) {
20
20
  };
21
21
  }
22
22
  export function parseFrontmatterDocument(source, options = {}) {
23
- const split = splitFrontmatter(source);
24
- const lineCounter = new LineCounter();
25
- if (split.raw === undefined) {
23
+ const split = inspectFrontmatterBlock(source);
24
+ const lineCounter = createSourceLineCounter(source);
25
+ if (split.kind === "body") {
26
26
  return {
27
27
  frontmatter: {},
28
28
  body: split.body,
@@ -30,14 +30,26 @@ export function parseFrontmatterDocument(source, options = {}) {
30
30
  lineCounter
31
31
  };
32
32
  }
33
- const document = parseDocument(normalizeYamlLineEndings(split.raw), {
34
- lineCounter,
33
+ if (split.kind === "missing-closing-fence") {
34
+ return {
35
+ frontmatter: {},
36
+ body: split.body,
37
+ errors: [{ message: split.message, pos: [split.position, split.position] }],
38
+ lineCounter
39
+ };
40
+ }
41
+ const yamlLineCounter = new LineCounter();
42
+ const normalizedYaml = normalizeYamlLineEndings(split.raw);
43
+ const document = parseDocument(normalizedYaml, {
44
+ lineCounter: yamlLineCounter,
35
45
  prettyErrors: false,
36
46
  uniqueKeys: options.uniqueKeys ?? false
37
47
  });
38
48
  const errors = document.errors.map((error) => ({
39
49
  message: error.message,
40
- ...(error.pos === undefined ? {} : { pos: error.pos })
50
+ ...(error.pos === undefined
51
+ ? {}
52
+ : { pos: translateYamlErrorPosition(error.pos, split) })
41
53
  }));
42
54
  if (errors.length > 0) {
43
55
  return {
@@ -47,12 +59,72 @@ export function parseFrontmatterDocument(source, options = {}) {
47
59
  lineCounter
48
60
  };
49
61
  }
50
- return {
51
- frontmatter: normalizeYamlFrontmatter(document.toJSON()),
52
- body: split.body,
53
- errors,
54
- lineCounter
55
- };
62
+ try {
63
+ return {
64
+ frontmatter: normalizeYamlFrontmatter(document.toJSON()),
65
+ body: split.body,
66
+ errors,
67
+ lineCounter
68
+ };
69
+ }
70
+ catch (error) {
71
+ if (error instanceof FrontmatterParseError) {
72
+ return {
73
+ frontmatter: {},
74
+ body: split.body,
75
+ errors: [{ message: error.message }],
76
+ lineCounter
77
+ };
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+ function createSourceLineCounter(source) {
83
+ const lineCounter = new LineCounter();
84
+ lineCounter.addNewLine(0);
85
+ for (let index = 0; index < source.length; index += 1) {
86
+ const character = source[index];
87
+ if (character === "\n") {
88
+ lineCounter.addNewLine(index + 1);
89
+ continue;
90
+ }
91
+ if (character === "\r") {
92
+ lineCounter.addNewLine(index + (source[index + 1] === "\n" ? 2 : 1));
93
+ if (source[index + 1] === "\n") {
94
+ index += 1;
95
+ }
96
+ }
97
+ }
98
+ return lineCounter;
99
+ }
100
+ function translateYamlErrorPosition(pos, split) {
101
+ return pos.map((offset) => split.rawStart + normalizeYamlErrorOffset(split.raw, offset));
102
+ }
103
+ function normalizeYamlErrorOffset(raw, offset) {
104
+ if (offset >= raw.length && endsWithLineBreak(raw)) {
105
+ return findPreviousLineStart(raw, raw.length);
106
+ }
107
+ return offset;
108
+ }
109
+ function endsWithLineBreak(value) {
110
+ return value.endsWith("\n") || value.endsWith("\r");
111
+ }
112
+ function findPreviousLineStart(value, end) {
113
+ let index = end - 1;
114
+ if (value[index] === "\n") {
115
+ index -= 1;
116
+ }
117
+ if (value[index] === "\r") {
118
+ index -= 1;
119
+ }
120
+ while (index >= 0) {
121
+ const character = value[index];
122
+ if (character === "\n" || character === "\r") {
123
+ return index + 1;
124
+ }
125
+ index -= 1;
126
+ }
127
+ return 0;
56
128
  }
57
129
  function splitFrontmatter(source) {
58
130
  try {
@@ -102,7 +174,7 @@ function normalizeYamlFrontmatter(value) {
102
174
  if (value === null || value === undefined) {
103
175
  return {};
104
176
  }
105
- if (!isRecord(value)) {
177
+ if (!isPlainRecord(value)) {
106
178
  throw new FrontmatterParseError("YAML frontmatter must parse to an object.");
107
179
  }
108
180
  return normalizeYamlValue(value);
@@ -2,6 +2,7 @@ import { stringify } from "yaml";
2
2
  import { FrontmatterParseError } from "./parse.js";
3
3
  export function stringifyFrontmatter(frontmatter, body) {
4
4
  try {
5
+ assertFrontmatterRoot(frontmatter);
5
6
  assertAcyclic(frontmatter);
6
7
  return `---\n${stringify(frontmatter, { aliasDuplicateObjects: false }).trimEnd()}\n---\n${body}`;
7
8
  }
@@ -13,6 +14,11 @@ export function stringifyFrontmatter(frontmatter, body) {
13
14
  throw new FrontmatterParseError(`Invalid YAML frontmatter: ${message}`);
14
15
  }
15
16
  }
17
+ function assertFrontmatterRoot(value) {
18
+ if (!isPlainRecord(value)) {
19
+ throw new FrontmatterParseError("YAML frontmatter must parse to an object.");
20
+ }
21
+ }
16
22
  function assertAcyclic(value, seen = new WeakSet()) {
17
23
  if (typeof value !== "object" || value === null) {
18
24
  return;
@@ -33,3 +39,10 @@ function assertAcyclic(value, seen = new WeakSet()) {
33
39
  }
34
40
  seen.delete(value);
35
41
  }
42
+ function isPlainRecord(value) {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
44
+ return false;
45
+ }
46
+ const prototype = Object.getPrototypeOf(value);
47
+ return prototype === Object.prototype || prototype === null;
48
+ }