@intentius/chant 0.0.18 → 0.0.24

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 (87) hide show
  1. package/bin/chant +4 -1
  2. package/package.json +20 -1
  3. package/src/build.test.ts +4 -2
  4. package/src/build.ts +3 -0
  5. package/src/builder.test.ts +3 -0
  6. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/astro.config.mjs +0 -3
  7. package/src/cli/commands/build.ts +5 -12
  8. package/src/cli/commands/diff.test.ts +2 -1
  9. package/src/cli/commands/diff.ts +2 -1
  10. package/src/cli/commands/init-lexicon/templates/codegen.ts +188 -0
  11. package/src/cli/commands/init-lexicon/templates/docs.ts +81 -0
  12. package/src/cli/commands/init-lexicon/templates/examples.ts +35 -0
  13. package/src/cli/commands/init-lexicon/templates/lint.ts +30 -0
  14. package/src/cli/commands/init-lexicon/templates/lsp.ts +39 -0
  15. package/src/cli/commands/init-lexicon/templates/plugin.ts +110 -0
  16. package/src/cli/commands/init-lexicon/templates/project.ts +182 -0
  17. package/src/cli/commands/init-lexicon/templates/spec.ts +57 -0
  18. package/src/cli/commands/init-lexicon/templates/tests.ts +70 -0
  19. package/src/cli/commands/init-lexicon.test.ts +0 -9
  20. package/src/cli/commands/init-lexicon.ts +12 -868
  21. package/src/cli/commands/init.ts +2 -20
  22. package/src/cli/conflict-check.test.ts +43 -0
  23. package/src/cli/handlers/build.ts +3 -3
  24. package/src/cli/handlers/lint.ts +2 -2
  25. package/src/cli/handlers/spell.ts +396 -0
  26. package/src/cli/handlers/state.ts +230 -0
  27. package/src/cli/lsp/server.test.ts +4 -0
  28. package/src/cli/main.ts +37 -3
  29. package/src/cli/mcp/resource-handlers.ts +227 -0
  30. package/src/cli/mcp/server.test.ts +13 -9
  31. package/src/cli/mcp/server.ts +24 -199
  32. package/src/cli/mcp/state-tools.ts +138 -0
  33. package/src/cli/mcp/tools/build.ts +2 -1
  34. package/src/cli/mcp/types.ts +45 -0
  35. package/src/cli/plugins.ts +1 -1
  36. package/src/cli/reporters/stylish.test.ts +2 -2
  37. package/src/cli/reporters/stylish.ts +1 -1
  38. package/src/codegen/docs-file-markers.ts +69 -0
  39. package/src/codegen/docs-rule-scanning.ts +159 -0
  40. package/src/codegen/docs-sections.ts +159 -0
  41. package/src/codegen/docs-sidebar.ts +56 -0
  42. package/src/codegen/docs-types.ts +79 -0
  43. package/src/codegen/docs.ts +9 -495
  44. package/src/composite.test.ts +76 -1
  45. package/src/composite.ts +37 -0
  46. package/src/config.ts +4 -0
  47. package/src/declarable.test.ts +2 -1
  48. package/src/declarable.ts +1 -1
  49. package/src/discovery/collect.test.ts +34 -0
  50. package/src/discovery/collect.ts +12 -0
  51. package/src/discovery/graph.test.ts +40 -0
  52. package/src/discovery/import.test.ts +5 -5
  53. package/src/discovery/resolve.test.ts +20 -0
  54. package/src/discovery/resolve.ts +2 -2
  55. package/src/index.ts +2 -0
  56. package/src/lexicon-plugin-helpers.ts +130 -0
  57. package/src/lexicon.ts +24 -0
  58. package/src/lint/rule-options.test.ts +3 -3
  59. package/src/lint/rule-registry.test.ts +1 -1
  60. package/src/lint/rules/composite-scope.ts +1 -1
  61. package/src/serializer-walker.ts +2 -1
  62. package/src/spell/discovery.ts +183 -0
  63. package/src/spell/index.ts +3 -0
  64. package/src/spell/prompt.ts +133 -0
  65. package/src/spell/types.ts +89 -0
  66. package/src/state/digest.ts +88 -0
  67. package/src/state/git.ts +317 -0
  68. package/src/state/index.ts +4 -0
  69. package/src/state/snapshot.ts +179 -0
  70. package/src/state/types.ts +59 -0
  71. package/src/toml-emit.ts +182 -0
  72. package/src/toml-parse.ts +370 -0
  73. package/src/toml-utils.ts +60 -0
  74. package/src/toml.ts +5 -602
  75. package/src/types.ts +2 -1
  76. package/src/utils.test.ts +16 -3
  77. package/src/utils.ts +31 -1
  78. package/src/validation.test.ts +11 -0
  79. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/getting-started.mdx +0 -6
  80. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/lint-rules.mdx +0 -6
  81. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content/docs/serialization.mdx +0 -6
  82. package/src/cli/commands/__fixtures__/init-lexicon-output/src/actions/.gitkeep +0 -0
  83. package/src/cli/commands/__fixtures__/init-lexicon-output/src/composites/.gitkeep +0 -0
  84. package/src/cli/commands/__fixtures__/init-lexicon-output/src/coverage.ts +0 -11
  85. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/generator.ts +0 -10
  86. package/src/cli/commands/__fixtures__/init-lexicon-output/src/import/parser.ts +0 -10
  87. package/src/cli/commands/__fixtures__/init-lexicon-output/src/lint/post-synth/.gitkeep +0 -0
@@ -6,66 +6,17 @@ import { importTool, handleImport } from "./tools/import";
6
6
  import { explainTool, handleExplain } from "./tools/explain";
7
7
  import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
8
8
  import { searchTool, createSearchHandler } from "./tools/search";
9
- import { getContext } from "./resources/context";
10
9
  import type { LexiconPlugin } from "../../lexicon";
11
- import type { McpToolContribution, McpResourceContribution } from "../../mcp/types";
12
-
13
- /**
14
- * MCP message types
15
- */
16
- interface McpRequest {
17
- jsonrpc: "2.0";
18
- id: string | number;
19
- method: string;
20
- params?: Record<string, unknown>;
21
- }
22
-
23
- interface McpResponse {
24
- jsonrpc: "2.0";
25
- id: string | number;
26
- result?: unknown;
27
- error?: {
28
- code: number;
29
- message: string;
30
- data?: unknown;
31
- };
32
- }
33
-
34
- interface McpNotification {
35
- jsonrpc: "2.0";
36
- method: string;
37
- params?: Record<string, unknown>;
38
- }
39
-
40
- /**
41
- * Tool definition for MCP
42
- */
43
- interface ToolDefinition {
44
- name: string;
45
- description: string;
46
- inputSchema: {
47
- type: "object";
48
- properties: Record<string, unknown>;
49
- required?: string[];
50
- };
51
- }
52
-
53
- /**
54
- * Resource definition for MCP
55
- */
56
- interface ResourceDefinition {
57
- uri: string;
58
- name: string;
59
- description: string;
60
- mimeType?: string;
61
- }
10
+ import type { McpRequest, McpResponse, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
+ import { createSnapshotTool, createDiffTool, createSpellDoneTool } from "./state-tools";
12
+ import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
62
13
 
63
14
  /**
64
15
  * MCP Server implementation
65
16
  */
66
17
  export class McpServer {
67
18
  private tools: Map<string, ToolDefinition> = new Map();
68
- private toolHandlers: Map<string, (params: Record<string, unknown>) => Promise<unknown>> = new Map();
19
+ private toolHandlers: Map<string, ToolHandler> = new Map();
69
20
  private pluginResources: Map<string, { definition: ResourceDefinition; handler: () => Promise<string> }> = new Map();
70
21
 
71
22
  constructor(plugins?: LexiconPlugin[]) {
@@ -77,6 +28,16 @@ export class McpServer {
77
28
  this.registerTool(scaffoldTool, createScaffoldHandler(plugins ?? []));
78
29
  this.registerTool(searchTool, createSearchHandler(plugins ?? []));
79
30
 
31
+ // Register state tools
32
+ const snapshot = createSnapshotTool(plugins ?? []);
33
+ this.registerTool(snapshot.definition, snapshot.handler);
34
+
35
+ const diff = createDiffTool(plugins ?? []);
36
+ this.registerTool(diff.definition, diff.handler);
37
+
38
+ const spellDone = createSpellDoneTool();
39
+ this.registerTool(spellDone.definition, spellDone.handler);
40
+
80
41
  // Register plugin contributions
81
42
  if (plugins) {
82
43
  for (const plugin of plugins) {
@@ -128,7 +89,7 @@ export class McpServer {
128
89
  */
129
90
  private registerTool(
130
91
  definition: ToolDefinition,
131
- handler: (params: Record<string, unknown>) => Promise<unknown>
92
+ handler: ToolHandler,
132
93
  ): void {
133
94
  this.tools.set(definition.name, definition);
134
95
  this.toolHandlers.set(definition.name, handler);
@@ -163,51 +124,29 @@ export class McpServer {
163
124
  private async dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
164
125
  switch (method) {
165
126
  case "initialize":
166
- return this.handleInitialize(params);
127
+ return {
128
+ protocolVersion: "2024-11-05",
129
+ capabilities: { tools: {}, resources: {} },
130
+ serverInfo: { name: "chant", version: "0.1.0" },
131
+ };
167
132
 
168
133
  case "tools/list":
169
- return this.handleToolsList();
134
+ return { tools: Array.from(this.tools.values()) };
170
135
 
171
136
  case "tools/call":
172
137
  return this.handleToolsCall(params);
173
138
 
174
139
  case "resources/list":
175
- return this.handleResourcesList();
140
+ return buildResourcesList(this.pluginResources);
176
141
 
177
142
  case "resources/read":
178
- return this.handleResourcesRead(params);
143
+ return handleResourcesRead(params, this.pluginResources);
179
144
 
180
145
  default:
181
146
  throw new Error(`Unknown method: ${method}`);
182
147
  }
183
148
  }
184
149
 
185
- /**
186
- * Handle initialize request
187
- */
188
- private handleInitialize(params: Record<string, unknown>): unknown {
189
- return {
190
- protocolVersion: "2024-11-05",
191
- capabilities: {
192
- tools: {},
193
- resources: {},
194
- },
195
- serverInfo: {
196
- name: "chant",
197
- version: "0.1.0",
198
- },
199
- };
200
- }
201
-
202
- /**
203
- * Handle tools/list request
204
- */
205
- private handleToolsList(): unknown {
206
- return {
207
- tools: Array.from(this.tools.values()),
208
- };
209
- }
210
-
211
150
  /**
212
151
  * Handle tools/call request
213
152
  */
@@ -218,12 +157,7 @@ export class McpServer {
218
157
  const handler = this.toolHandlers.get(name);
219
158
  if (!handler) {
220
159
  return {
221
- content: [
222
- {
223
- type: "text",
224
- text: `Error: Unknown tool: ${name}`,
225
- },
226
- ],
160
+ content: [{ type: "text", text: `Error: Unknown tool: ${name}` }],
227
161
  isError: true,
228
162
  };
229
163
  }
@@ -251,115 +185,6 @@ export class McpServer {
251
185
  }
252
186
  }
253
187
 
254
- /**
255
- * Handle resources/list request — merges core + plugin resources
256
- */
257
- private handleResourcesList(): unknown {
258
- const resources: ResourceDefinition[] = [
259
- {
260
- uri: "chant://context",
261
- name: "chant Context",
262
- description: "Lexicon-specific instructions and patterns for chant development",
263
- mimeType: "text/markdown",
264
- },
265
- {
266
- uri: "chant://examples/list",
267
- name: "Examples List",
268
- description: "List of available chant examples",
269
- mimeType: "application/json",
270
- },
271
- ];
272
-
273
- // Merge plugin resources
274
- for (const { definition } of this.pluginResources.values()) {
275
- resources.push(definition);
276
- }
277
-
278
- return { resources };
279
- }
280
-
281
- /**
282
- * Collect example resources from plugins whose URI contains "examples/"
283
- */
284
- private collectExamples(): Array<{ name: string; description: string }> {
285
- const examples: Array<{ name: string; description: string }> = [];
286
- for (const [uri, { definition }] of this.pluginResources.entries()) {
287
- if (uri.includes("/examples/")) {
288
- const name = uri.replace(/^chant:\/\/[^/]+\/examples\//, "");
289
- examples.push({ name, description: definition.description });
290
- }
291
- }
292
- return examples;
293
- }
294
-
295
- /**
296
- * Handle resources/read request — checks plugin resources after core
297
- */
298
- private async handleResourcesRead(params: Record<string, unknown>): Promise<unknown> {
299
- const uri = params.uri as string;
300
-
301
- if (uri === "chant://context") {
302
- return {
303
- contents: [
304
- {
305
- uri,
306
- mimeType: "text/markdown",
307
- text: getContext(),
308
- },
309
- ],
310
- };
311
- }
312
-
313
- if (uri === "chant://examples/list") {
314
- return {
315
- contents: [
316
- {
317
- uri,
318
- mimeType: "application/json",
319
- text: JSON.stringify(this.collectExamples()),
320
- },
321
- ],
322
- };
323
- }
324
-
325
- if (uri.startsWith("chant://examples/")) {
326
- // Look up example in plugin resources
327
- const name = uri.replace("chant://examples/", "");
328
- for (const [pluginUri, pluginResource] of this.pluginResources.entries()) {
329
- if (pluginUri.endsWith(`/examples/${name}`)) {
330
- const text = await pluginResource.handler();
331
- return {
332
- contents: [
333
- {
334
- uri,
335
- mimeType: pluginResource.definition.mimeType ?? "text/typescript",
336
- text,
337
- },
338
- ],
339
- };
340
- }
341
- }
342
- throw new Error(`Example not found: ${name}`);
343
- }
344
-
345
- // Check plugin resources
346
- const pluginResource = this.pluginResources.get(uri);
347
- if (pluginResource) {
348
- const text = await pluginResource.handler();
349
- return {
350
- contents: [
351
- {
352
- uri,
353
- mimeType: pluginResource.definition.mimeType ?? "text/plain",
354
- text,
355
- },
356
- ],
357
- };
358
- }
359
-
360
- throw new Error(`Unknown resource: ${uri}`);
361
- }
362
-
363
188
  /**
364
189
  * Start the MCP server on stdio
365
190
  */
@@ -0,0 +1,138 @@
1
+ import { resolve } from "node:path";
2
+ import type { LexiconPlugin } from "../../lexicon";
3
+ import type { ToolDefinition, ToolHandler } from "./types";
4
+ import { readSnapshot } from "../../state/git";
5
+ import { build } from "../../build";
6
+ import { computeBuildDigest, diffDigests } from "../../state/digest";
7
+ import { takeSnapshot } from "../../state/snapshot";
8
+ import type { StateSnapshot } from "../../state/types";
9
+ import { discoverSpells } from "../../spell/discovery";
10
+
11
+ export interface ToolRegistration {
12
+ definition: ToolDefinition;
13
+ handler: ToolHandler;
14
+ }
15
+
16
+ /**
17
+ * Create state-snapshot tool definition and handler
18
+ */
19
+ export function createSnapshotTool(plugins: LexiconPlugin[]): ToolRegistration {
20
+ return {
21
+ definition: {
22
+ name: "state-snapshot",
23
+ description: "Capture deployed state for an environment",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ environment: { type: "string", description: "Target environment" },
28
+ lexicon: { type: "string", description: "Optional — snapshot all lexicons if omitted" },
29
+ },
30
+ required: ["environment"],
31
+ },
32
+ },
33
+ handler: async (params) => {
34
+ const env = params.environment as string;
35
+ const lexiconFilter = params.lexicon as string | undefined;
36
+ const targetPlugins = lexiconFilter
37
+ ? plugins.filter((p) => p.name === lexiconFilter)
38
+ : plugins;
39
+ const pluginsWithDescribe = targetPlugins.filter((p) => p.describeResources);
40
+ if (pluginsWithDescribe.length === 0) return "No plugins implement describeResources";
41
+ const serializers = plugins.map((p) => p.serializer);
42
+ const buildResult = await build(resolve("."), serializers);
43
+ if (buildResult.errors.length > 0) return "Build failed";
44
+ const result = await takeSnapshot(env, pluginsWithDescribe, buildResult);
45
+ return { snapshots: result.snapshots.length, warnings: result.warnings, errors: result.errors };
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Create state-diff tool definition and handler
52
+ */
53
+ export function createDiffTool(plugins: LexiconPlugin[]): ToolRegistration {
54
+ return {
55
+ definition: {
56
+ name: "state-diff",
57
+ description: "Compare current build declarations against last snapshot's digest",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ environment: { type: "string", description: "Target environment" },
62
+ lexicon: { type: "string", description: "Optional — diff all lexicons if omitted" },
63
+ },
64
+ required: ["environment"],
65
+ },
66
+ },
67
+ handler: async (params) => {
68
+ const env = params.environment as string;
69
+ const lexiconFilter = params.lexicon as string | undefined;
70
+ const serializers = plugins.map((p) => p.serializer);
71
+ const buildResult = await build(resolve("."), serializers);
72
+ if (buildResult.errors.length > 0) return "Build failed";
73
+ const currentDigest = computeBuildDigest(buildResult);
74
+ const lexicons = lexiconFilter ? [lexiconFilter] : buildResult.manifest.lexicons;
75
+ const results: Record<string, unknown> = {};
76
+ for (const lex of lexicons) {
77
+ const content = await readSnapshot(env, lex);
78
+ let previousDigest = undefined;
79
+ if (content) {
80
+ const snapshot: StateSnapshot = JSON.parse(content);
81
+ previousDigest = snapshot.digest;
82
+ }
83
+ results[lex] = diffDigests(currentDigest, previousDigest);
84
+ }
85
+ return results;
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Create spell-done tool definition and handler
92
+ */
93
+ export function createSpellDoneTool(): ToolRegistration {
94
+ return {
95
+ definition: {
96
+ name: "spell-done",
97
+ description: "Mark a spell task as done",
98
+ inputSchema: {
99
+ type: "object",
100
+ properties: {
101
+ name: { type: "string", description: "Spell name" },
102
+ taskNumber: { type: "number", description: "Task number (1-based)" },
103
+ },
104
+ required: ["name", "taskNumber"],
105
+ },
106
+ },
107
+ handler: async (params) => {
108
+ const { readFileSync, writeFileSync } = await import("node:fs");
109
+ const { spells } = await discoverSpells();
110
+ const name = params.name as string;
111
+ const taskNumber = params.taskNumber as number;
112
+ const spell = spells.get(name);
113
+ if (!spell) return `Spell "${name}" not found`;
114
+ if (taskNumber < 1 || taskNumber > spell.definition.tasks.length) {
115
+ return `Invalid task number ${taskNumber}`;
116
+ }
117
+ const task = spell.definition.tasks[taskNumber - 1];
118
+ if (task.done) return `Task ${taskNumber} is already done`;
119
+
120
+ const content = readFileSync(spell.filePath, "utf-8");
121
+ let count = 0;
122
+ const rewritten = content.replace(
123
+ /task\(("[^"]*"|'[^']*'|`[^`]*`)((?:\s*,\s*\{[^}]*\})?)\)/g,
124
+ (match, desc, opts) => {
125
+ count++;
126
+ if (count !== taskNumber) return match;
127
+ if (opts && opts.includes("done:")) {
128
+ return match.replace(/done:\s*false/, "done: true");
129
+ }
130
+ return `task(${desc}, { done: true })`;
131
+ },
132
+ );
133
+ if (rewritten === content) return `Could not rewrite task ${taskNumber}`;
134
+ writeFileSync(spell.filePath, rewritten);
135
+ return `Task ${taskNumber} marked done: "${task.description}"`;
136
+ },
137
+ };
138
+ }
@@ -58,7 +58,8 @@ export async function handleBuild(params: Record<string, unknown>): Promise<unkn
58
58
  // Combine all lexicon outputs
59
59
  const combined: Record<string, unknown> = {};
60
60
  for (const [lexiconName, lexiconOutput] of result.outputs) {
61
- combined[lexiconName] = JSON.parse(lexiconOutput);
61
+ const raw = lexiconOutput;
62
+ combined[lexiconName] = JSON.parse(typeof raw === "string" ? raw : raw.primary);
62
63
  }
63
64
 
64
65
  let output = JSON.stringify(combined, null, 2);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * MCP message types
3
+ */
4
+ export interface McpRequest {
5
+ jsonrpc: "2.0";
6
+ id: string | number;
7
+ method: string;
8
+ params?: Record<string, unknown>;
9
+ }
10
+
11
+ export interface McpResponse {
12
+ jsonrpc: "2.0";
13
+ id: string | number;
14
+ result?: unknown;
15
+ error?: {
16
+ code: number;
17
+ message: string;
18
+ data?: unknown;
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Tool definition for MCP
24
+ */
25
+ export interface ToolDefinition {
26
+ name: string;
27
+ description: string;
28
+ inputSchema: {
29
+ type: "object";
30
+ properties: Record<string, unknown>;
31
+ required?: string[];
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Resource definition for MCP
37
+ */
38
+ export interface ResourceDefinition {
39
+ uri: string;
40
+ name: string;
41
+ description: string;
42
+ mimeType?: string;
43
+ }
44
+
45
+ export type ToolHandler = (params: Record<string, unknown>) => Promise<unknown>;
@@ -33,7 +33,7 @@ export async function loadPlugin(lexiconName: string): Promise<LexiconPlugin> {
33
33
  validate: notSupported("validate"),
34
34
  coverage: notSupported("coverage"),
35
35
  package: notSupported("package"),
36
- rollback: notSupported("rollback"),
36
+
37
37
  };
38
38
  }
39
39
 
@@ -17,9 +17,9 @@ describe("formatStylish", () => {
17
17
  }
18
18
  });
19
19
 
20
- test("returns empty string for no diagnostics", () => {
20
+ test("returns summary line for no diagnostics", () => {
21
21
  const result = formatStylish([]);
22
- expect(result).toBe("");
22
+ expect(result).toBe("\u2713 No problems found");
23
23
  });
24
24
 
25
25
  test("formats single diagnostic", () => {
@@ -36,7 +36,7 @@ function color(text: string, colorCode: string): string {
36
36
  */
37
37
  export function formatStylish(diagnostics: LintDiagnostic[]): string {
38
38
  if (diagnostics.length === 0) {
39
- return "";
39
+ return formatSummary(0, 0);
40
40
  }
41
41
 
42
42
  // Group by file
@@ -0,0 +1,69 @@
1
+ /**
2
+ * File marker interpolation and MDX escaping utilities.
3
+ */
4
+
5
+ import { readFileSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ /** Escape curly braces so MDX doesn't treat them as JSX expressions. */
9
+ export function escapeMdx(text: string): string {
10
+ return text.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
11
+ }
12
+
13
+ /**
14
+ * Expand `{{file:path.ts}}` markers in content with fenced code blocks.
15
+ *
16
+ * Supported forms:
17
+ * - `{{file:path.ts}}` — full file
18
+ * - `{{file:path.ts:5-12}}` — lines 5–12 (1-based, inclusive)
19
+ * - `{{file:path.ts|title=custom.ts}}` — override the code block title
20
+ * - `{{file:path.ts:5-12|title=custom.ts}}` — both
21
+ */
22
+ export function expandFileMarkers(content: string, examplesDir: string): string {
23
+ return content.replace(
24
+ /\{\{file:([^}]+)\}\}/g,
25
+ (_match, spec: string) => {
26
+ // Parse options after |
27
+ let filePart = spec;
28
+ let title: string | undefined;
29
+ const pipeIdx = spec.indexOf("|");
30
+ if (pipeIdx !== -1) {
31
+ filePart = spec.substring(0, pipeIdx);
32
+ const opts = spec.substring(pipeIdx + 1);
33
+ const titleMatch = opts.match(/title=([^\s|]+)/);
34
+ if (titleMatch) title = titleMatch[1];
35
+ }
36
+
37
+ // Parse line range after :digits-digits at end of filePart
38
+ let lineStart: number | undefined;
39
+ let lineEnd: number | undefined;
40
+ const rangeMatch = filePart.match(/^(.+):(\d+)-(\d+)$/);
41
+ if (rangeMatch) {
42
+ filePart = rangeMatch[1];
43
+ lineStart = parseInt(rangeMatch[2], 10);
44
+ lineEnd = parseInt(rangeMatch[3], 10);
45
+ }
46
+
47
+ const filePath = join(examplesDir, filePart);
48
+ let fileContent: string;
49
+ try {
50
+ fileContent = readFileSync(filePath, "utf-8");
51
+ } catch {
52
+ throw new Error(`File marker {{file:${spec}}} — file not found: ${filePath}`);
53
+ }
54
+
55
+ // Extract line range if specified
56
+ if (lineStart !== undefined && lineEnd !== undefined) {
57
+ const lines = fileContent.split("\n");
58
+ fileContent = lines.slice(lineStart - 1, lineEnd).join("\n");
59
+ }
60
+
61
+ // Determine language from extension
62
+ const ext = filePart.substring(filePart.lastIndexOf(".") + 1);
63
+ const lang = ext === "ts" || ext === "tsx" ? "typescript" : ext;
64
+ const displayTitle = title ?? filePart.substring(filePart.lastIndexOf("/") + 1);
65
+
66
+ return `\`\`\`${lang} title="${displayTitle}"\n${fileContent.trimEnd()}\n\`\`\``;
67
+ },
68
+ );
69
+ }