@intentius/chant 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
+ "license": "Apache-2.0",
4
5
  "type": "module",
5
6
  "files": ["src/"],
6
7
  "publishConfig": {
package/src/build.ts CHANGED
@@ -53,6 +53,8 @@ export function partitionByLexicon(
53
53
  const partitions = new Map<string, Map<string, Declarable>>();
54
54
 
55
55
  for (const [name, entity] of entities) {
56
+ // LexiconOutput instances are collected separately; skip them here
57
+ if (isLexiconOutput(entity)) continue;
56
58
  const lexicon = entity.lexicon;
57
59
  if (!partitions.has(lexicon)) {
58
60
  partitions.set(lexicon, new Map());
@@ -100,7 +102,18 @@ export function collectLexiconOutputs(
100
102
  for (const [name, entity] of entities) {
101
103
  if (isLexiconOutput(entity as unknown)) {
102
104
  const lexiconOutput = entity as unknown as LexiconOutput;
103
- lexiconOutput._setSourceEntity(name);
105
+ // Resolve source entity name from the WeakRef parent identity
106
+ const parent = lexiconOutput._sourceParent.deref();
107
+ let sourceName = name;
108
+ if (parent) {
109
+ for (const [entityName, e] of entities) {
110
+ if (e === parent) {
111
+ sourceName = entityName;
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ lexiconOutput._setSourceEntity(sourceName);
104
117
  outputs.push(lexiconOutput);
105
118
  continue;
106
119
  }
@@ -0,0 +1,77 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { expandFileMarkers } from "./docs";
6
+
7
+ describe("expandFileMarkers", () => {
8
+ let dir: string;
9
+
10
+ beforeAll(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "docs-interp-"));
12
+ mkdirSync(join(dir, "sub"), { recursive: true });
13
+ writeFileSync(
14
+ join(dir, "example.ts"),
15
+ 'import * as _ from "./_";\n\nexport const bucket = new _.Bucket({\n bucketName: "test",\n});\n',
16
+ );
17
+ writeFileSync(
18
+ join(dir, "sub", "nested.ts"),
19
+ "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n",
20
+ );
21
+ });
22
+
23
+ afterAll(() => {
24
+ rmSync(dir, { recursive: true, force: true });
25
+ });
26
+
27
+ test("expands full file marker", () => {
28
+ const result = expandFileMarkers("Before\n\n{{file:example.ts}}\n\nAfter", dir);
29
+ expect(result).toContain('```typescript title="example.ts"');
30
+ expect(result).toContain('import * as _ from "./_";');
31
+ expect(result).toContain("```");
32
+ expect(result).toStartWith("Before\n\n");
33
+ expect(result).toEndWith("\n\nAfter");
34
+ });
35
+
36
+ test("expands line range", () => {
37
+ const result = expandFileMarkers("{{file:sub/nested.ts:3-5}}", dir);
38
+ expect(result).toContain('```typescript title="nested.ts"');
39
+ expect(result).toContain("line3\nline4\nline5");
40
+ expect(result).not.toContain("line2");
41
+ expect(result).not.toContain("line6");
42
+ });
43
+
44
+ test("supports title override", () => {
45
+ const result = expandFileMarkers("{{file:example.ts|title=my-file.ts}}", dir);
46
+ expect(result).toContain('```typescript title="my-file.ts"');
47
+ });
48
+
49
+ test("supports line range with title override", () => {
50
+ const result = expandFileMarkers(
51
+ "{{file:sub/nested.ts:2-4|title=snippet.ts}}",
52
+ dir,
53
+ );
54
+ expect(result).toContain('```typescript title="snippet.ts"');
55
+ expect(result).toContain("line2\nline3\nline4");
56
+ });
57
+
58
+ test("throws on missing file", () => {
59
+ expect(() => expandFileMarkers("{{file:nope.ts}}", dir)).toThrow(
60
+ /file not found/,
61
+ );
62
+ });
63
+
64
+ test("leaves content without markers unchanged", () => {
65
+ const input = "No markers here\n\n```typescript\ncode\n```\n";
66
+ expect(expandFileMarkers(input, dir)).toBe(input);
67
+ });
68
+
69
+ test("expands multiple markers", () => {
70
+ const result = expandFileMarkers(
71
+ "{{file:example.ts}}\n\n{{file:sub/nested.ts:1-2}}",
72
+ dir,
73
+ );
74
+ expect(result).toContain('title="example.ts"');
75
+ expect(result).toContain('title="nested.ts"');
76
+ });
77
+ });
@@ -39,6 +39,8 @@ export interface DocsConfig {
39
39
  srcDir?: string;
40
40
  /** Base path for the generated Astro site (e.g. '/lexicons/aws/') */
41
41
  basePath?: string;
42
+ /** Root directory for resolving {{file:...}} markers in extra page content */
43
+ examplesDir?: string;
42
44
  }
43
45
 
44
46
  export interface DocsResult {
@@ -84,6 +86,66 @@ interface RuleMeta {
84
86
  type: "lint" | "post-synth";
85
87
  }
86
88
 
89
+ // ── File marker interpolation ──────────────────────────────────────
90
+
91
+ /**
92
+ * Expand `{{file:path.ts}}` markers in content with fenced code blocks.
93
+ *
94
+ * Supported forms:
95
+ * - `{{file:path.ts}}` — full file
96
+ * - `{{file:path.ts:5-12}}` — lines 5–12 (1-based, inclusive)
97
+ * - `{{file:path.ts|title=custom.ts}}` — override the code block title
98
+ * - `{{file:path.ts:5-12|title=custom.ts}}` — both
99
+ */
100
+ export function expandFileMarkers(content: string, examplesDir: string): string {
101
+ return content.replace(
102
+ /\{\{file:([^}]+)\}\}/g,
103
+ (_match, spec: string) => {
104
+ // Parse options after |
105
+ let filePart = spec;
106
+ let title: string | undefined;
107
+ const pipeIdx = spec.indexOf("|");
108
+ if (pipeIdx !== -1) {
109
+ filePart = spec.substring(0, pipeIdx);
110
+ const opts = spec.substring(pipeIdx + 1);
111
+ const titleMatch = opts.match(/title=([^\s|]+)/);
112
+ if (titleMatch) title = titleMatch[1];
113
+ }
114
+
115
+ // Parse line range after :digits-digits at end of filePart
116
+ let lineStart: number | undefined;
117
+ let lineEnd: number | undefined;
118
+ const rangeMatch = filePart.match(/^(.+):(\d+)-(\d+)$/);
119
+ if (rangeMatch) {
120
+ filePart = rangeMatch[1];
121
+ lineStart = parseInt(rangeMatch[2], 10);
122
+ lineEnd = parseInt(rangeMatch[3], 10);
123
+ }
124
+
125
+ const filePath = join(examplesDir, filePart);
126
+ let fileContent: string;
127
+ try {
128
+ fileContent = readFileSync(filePath, "utf-8");
129
+ } catch {
130
+ throw new Error(`File marker {{file:${spec}}} — file not found: ${filePath}`);
131
+ }
132
+
133
+ // Extract line range if specified
134
+ if (lineStart !== undefined && lineEnd !== undefined) {
135
+ const lines = fileContent.split("\n");
136
+ fileContent = lines.slice(lineStart - 1, lineEnd).join("\n");
137
+ }
138
+
139
+ // Determine language from extension
140
+ const ext = filePart.substring(filePart.lastIndexOf(".") + 1);
141
+ const lang = ext === "ts" || ext === "tsx" ? "typescript" : ext;
142
+ const displayTitle = title ?? filePart.substring(filePart.lastIndexOf("/") + 1);
143
+
144
+ return `\`\`\`${lang} title="${displayTitle}"\n${fileContent.trimEnd()}\n\`\`\``;
145
+ },
146
+ );
147
+ }
148
+
87
149
  // ── Pipeline ───────────────────────────────────────────────────────
88
150
 
89
151
  /**
@@ -137,6 +199,10 @@ export function docsPipeline(config: DocsConfig): DocsResult {
137
199
  // Extra pages from lexicon config
138
200
  if (config.extraPages) {
139
201
  for (const page of config.extraPages) {
202
+ let content = page.content;
203
+ if (config.examplesDir) {
204
+ content = expandFileMarkers(content, config.examplesDir);
205
+ }
140
206
  pages.set(
141
207
  `${page.slug}.mdx`,
142
208
  [
@@ -145,7 +211,7 @@ export function docsPipeline(config: DocsConfig): DocsResult {
145
211
  page.description ? `description: "${page.description}"` : "",
146
212
  "---",
147
213
  "",
148
- page.content,
214
+ content,
149
215
  "",
150
216
  ]
151
217
  .filter(Boolean)
@@ -300,7 +366,15 @@ function buildSidebar(
300
366
  config: DocsConfig,
301
367
  result: DocsResult,
302
368
  ): Array<Record<string, unknown>> {
369
+ // Starlight prepends basePath to every sidebar `link`, so a site-root-relative
370
+ // path like "/chant/" becomes "/chant/lexicons/aws/chant/" — a 404. Instead
371
+ // we use relative traversal: "../../" is prepended to become
372
+ // "/chant/lexicons/aws/../../" which the browser resolves to "/chant/".
373
+ const segments = (config.basePath ?? "/").replace(/^\/|\/$/g, "").split("/");
374
+ const backLink = segments.length > 1 ? "../".repeat(segments.length - 1) : "/";
375
+
303
376
  const items: Array<Record<string, unknown>> = [
377
+ { label: "← chant docs", link: backLink },
304
378
  { label: "Overview", slug: "index" },
305
379
  ];
306
380
 
@@ -1,11 +1,14 @@
1
1
  import { isDeclarable, type Declarable } from "../declarable";
2
2
  import { isCompositeInstance, expandComposite } from "../composite";
3
+ import { isLexiconOutput } from "../lexicon-output";
3
4
  import { DiscoveryError } from "../errors";
4
5
 
5
6
  /**
6
7
  * Collects all declarable entities from imported modules.
7
8
  * CompositeInstance exports are expanded into individual entities
8
9
  * with `{exportName}_{memberName}` naming.
10
+ * LexiconOutput exports are also collected so that build() can
11
+ * extract them and pass them to the serializer.
9
12
  *
10
13
  * @param modules - Array of module records with their exports
11
14
  * @returns Map of export name to Declarable entity
@@ -43,6 +46,10 @@ export function collectEntities(
43
46
  }
44
47
  entities.set(expandedName, entity);
45
48
  }
49
+ } else if (isLexiconOutput(value)) {
50
+ // LexiconOutput is not a Declarable but build() expects to find them
51
+ // in the entities map so it can collect and pass them to serializers
52
+ entities.set(name, value as unknown as Declarable);
46
53
  }
47
54
  }
48
55
  }