@notrealstudio/nr-md 0.1.1 → 0.2.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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Denis Vinogradsky
3
+ Copyright (c) 2026 Julian Garrett / Not Real Studio
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -3,6 +3,26 @@ export interface IncludeOptions {
3
3
  maxDepth?: number;
4
4
  /** Active sigil — only this sigil's includes are resolved. */
5
5
  sigil?: '$' | '@';
6
+ /**
7
+ * Canonical id (e.g. file path) of the root document. Passed to
8
+ * `resolvePath` as `from` for top-level includes.
9
+ */
10
+ from?: string;
11
+ /**
12
+ * Map a target as written in document `from` to a canonical id. When set,
13
+ * `readFile` receives the id (not the raw target), nested includes resolve
14
+ * against the file they are written in, and cycles are detected by id.
15
+ * `undefined` = not found (include left as-is). Without it targets are
16
+ * passed through verbatim (legacy behaviour).
17
+ */
18
+ resolvePath?: (target: string, from: string | undefined) => string | undefined;
19
+ /** On cycle: 'keep' (default) leaves the include as-is, 'throw' raises IncludeCycleError. */
20
+ onCycle?: 'keep' | 'throw';
21
+ }
22
+ /** Include cycle. `chain` — ids from the first repeated one back to itself. */
23
+ export declare class IncludeCycleError extends Error {
24
+ readonly chain: string[];
25
+ constructor(chain: string[]);
6
26
  }
7
27
  /**
8
28
  * Resolve file includes in text. Replaces `$[[path]]` / `$[[path#section]]`
package/dist/includes.js CHANGED
@@ -1,6 +1,43 @@
1
1
  // File includes: $[[path]] / @[[path]] preprocessor (format-spec §2.6).
2
2
  // Text-level substitution BEFORE parsing. Recursive with cycle detection.
3
3
  // Supports sections: $[[path#section]], $[[#section]] (from current text).
4
+ // Path resolution is the caller's: `resolvePath(target, from)` makes nested
5
+ // includes relative to the file they are written in (the core stays IO-free).
6
+ /** Include cycle. `chain` — ids from the first repeated one back to itself. */
7
+ export class IncludeCycleError extends Error {
8
+ chain;
9
+ constructor(chain) {
10
+ super(`include cycle: ${chain.join(' -> ')}`);
11
+ this.name = 'IncludeCycleError';
12
+ this.chain = chain;
13
+ }
14
+ }
15
+ function makeCtx(readFile, opts) {
16
+ return {
17
+ readFile,
18
+ sigil: opts?.sigil ?? '$',
19
+ maxDepth: opts?.maxDepth ?? 10,
20
+ resolvePath: opts?.resolvePath,
21
+ onCycle: opts?.onCycle ?? 'keep',
22
+ stack: opts?.from !== undefined && opts.resolvePath ? [opts.from] : [],
23
+ };
24
+ }
25
+ /** Resolve target → id; `undefined` = not found. */
26
+ function toId(ctx, target) {
27
+ if (!ctx.resolvePath)
28
+ return target;
29
+ const from = ctx.stack.length ? ctx.stack[ctx.stack.length - 1] : undefined;
30
+ return ctx.resolvePath(target, from);
31
+ }
32
+ /** true — cycle, include must be left as-is (or it threw). */
33
+ function isCycle(ctx, id) {
34
+ const at = ctx.stack.indexOf(id);
35
+ if (at < 0)
36
+ return false;
37
+ if (ctx.onCycle === 'throw')
38
+ throw new IncludeCycleError([...ctx.stack.slice(at), id]);
39
+ return true;
40
+ }
4
41
  /**
5
42
  * Resolve file includes in text. Replaces `$[[path]]` / `$[[path#section]]`
6
43
  * with file/section content, recursively. Cycle detection by path.
@@ -11,17 +48,13 @@
11
48
  * Async version: resolveIncludesAsync.
12
49
  */
13
50
  export function resolveIncludes(text, readFile, opts) {
14
- const sigil = opts?.sigil ?? '$';
15
- const maxDepth = opts?.maxDepth ?? 10;
16
- return expand(text, readFile, sigil, maxDepth, new Set(), 0, text);
51
+ return expand(text, makeCtx(readFile, opts), 0, text);
17
52
  }
18
53
  /**
19
54
  * Async version — readFile returns Promise.
20
55
  */
21
56
  export async function resolveIncludesAsync(text, readFile, opts) {
22
- const sigil = opts?.sigil ?? '$';
23
- const maxDepth = opts?.maxDepth ?? 10;
24
- return expandAsync(text, readFile, sigil, maxDepth, new Set(), 0, text);
57
+ return expandAsync(text, makeCtx(readFile, opts), 0, text);
25
58
  }
26
59
  // ---------- Extract section from markdown ----------
27
60
  /**
@@ -69,7 +102,8 @@ function headingLevel(line) {
69
102
  return i;
70
103
  }
71
104
  // ---------- Sync expand ----------
72
- function expand(text, readFile, sigil, maxDepth, visited, depth, currentText) {
105
+ function expand(text, ctx, depth, currentText) {
106
+ const { sigil, maxDepth } = ctx;
73
107
  if (depth > maxDepth)
74
108
  return text;
75
109
  let out = '';
@@ -116,12 +150,12 @@ function expand(text, readFile, sigil, maxDepth, visited, depth, currentText) {
116
150
  }
117
151
  continue;
118
152
  }
119
- // Cycle detection
120
- if (visited.has(filePath)) {
153
+ const id = toId(ctx, filePath);
154
+ if (id === undefined || isCycle(ctx, id)) {
121
155
  out += text.slice(start, i);
122
156
  continue;
123
157
  }
124
- const content = readFile(filePath);
158
+ const content = ctx.readFile(id);
125
159
  if (content === undefined) {
126
160
  out += text.slice(start, i);
127
161
  continue;
@@ -137,9 +171,13 @@ function expand(text, readFile, sigil, maxDepth, visited, depth, currentText) {
137
171
  result = extracted;
138
172
  }
139
173
  // Recurse
140
- visited.add(filePath);
141
- out += expand(result, readFile, sigil, maxDepth, visited, depth + 1, content);
142
- visited.delete(filePath);
174
+ ctx.stack.push(id);
175
+ try {
176
+ out += expand(result, ctx, depth + 1, content);
177
+ }
178
+ finally {
179
+ ctx.stack.pop();
180
+ }
143
181
  continue;
144
182
  }
145
183
  out += text[i];
@@ -148,7 +186,8 @@ function expand(text, readFile, sigil, maxDepth, visited, depth, currentText) {
148
186
  return out;
149
187
  }
150
188
  // ---------- Async expand ----------
151
- async function expandAsync(text, readFile, sigil, maxDepth, visited, depth, currentText) {
189
+ async function expandAsync(text, ctx, depth, currentText) {
190
+ const { sigil, maxDepth } = ctx;
152
191
  if (depth > maxDepth)
153
192
  return text;
154
193
  // Collect all include positions first, then resolve in order
@@ -188,11 +227,12 @@ async function expandAsync(text, readFile, sigil, maxDepth, visited, depth, curr
188
227
  out += extracted ?? text.slice(start, i);
189
228
  continue;
190
229
  }
191
- if (visited.has(filePath)) {
230
+ const id = toId(ctx, filePath);
231
+ if (id === undefined || isCycle(ctx, id)) {
192
232
  out += text.slice(start, i);
193
233
  continue;
194
234
  }
195
- const content = await readFile(filePath);
235
+ const content = await ctx.readFile(id);
196
236
  if (content === undefined) {
197
237
  out += text.slice(start, i);
198
238
  continue;
@@ -206,9 +246,13 @@ async function expandAsync(text, readFile, sigil, maxDepth, visited, depth, curr
206
246
  }
207
247
  result = extracted;
208
248
  }
209
- visited.add(filePath);
210
- out += await expandAsync(result, readFile, sigil, maxDepth, visited, depth + 1, content);
211
- visited.delete(filePath);
249
+ ctx.stack.push(id);
250
+ try {
251
+ out += await expandAsync(result, ctx, depth + 1, content);
252
+ }
253
+ finally {
254
+ ctx.stack.pop();
255
+ }
212
256
  continue;
213
257
  }
214
258
  out += text[i];
package/dist/index.d.ts CHANGED
@@ -6,6 +6,6 @@ export { serialize, serializeValue, serializeTable, serializeTypedTable, parseTa
6
6
  export type { TableSerializeOptions, TableRecord } from './serialize.js';
7
7
  export { parseHeaderCell, emitHeaderCell, coerceTyped, tableSchema, TableParseError } from './typed-header.js';
8
8
  export type { TypedColumn, ColumnType, JSONSchema } from './typed-header.js';
9
- export { resolveIncludes, resolveIncludesAsync, extractSection } from './includes.js';
9
+ export { resolveIncludes, resolveIncludesAsync, extractSection, IncludeCycleError } from './includes.js';
10
10
  export type { IncludeOptions } from './includes.js';
11
11
  export type { Sigil, ParseOptions, SerializeOptions, Document, Block, Attribute, Pos, AttributeValue, BodyValue, Scalar, ListItem, Json5Object, Json5Value, InterpolatedValue, InterpolationSpan, } from './types.js';
package/dist/index.js CHANGED
@@ -20,4 +20,4 @@ export { serialize, serializeValue, serializeTable, serializeTypedTable, parseTa
20
20
  // ---------- Typed tbl header + tableSchema (serialize-spec §2.3) ----------
21
21
  export { parseHeaderCell, emitHeaderCell, coerceTyped, tableSchema, TableParseError } from './typed-header.js';
22
22
  // ---------- Includes (format-spec §2.6) ----------
23
- export { resolveIncludes, resolveIncludesAsync, extractSection } from './includes.js';
23
+ export { resolveIncludes, resolveIncludesAsync, extractSection, IncludeCycleError } from './includes.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notrealstudio/nr-md",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Markdown as structured data: headings become blocks, $key: value lines become attributes. Parser and serializer for the mdd/mdz block grammar.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -34,7 +34,8 @@
34
34
  "scripts": {
35
35
  "build": "tsc",
36
36
  "test": "vitest run",
37
- "test:watch": "vitest"
37
+ "test:watch": "vitest",
38
+ "prepublishOnly": "node -e \"const p=require('./package.json');const bad=Object.entries({...p.dependencies,...p.peerDependencies}).filter(([,v])=>String(v).startsWith('file:'));if(bad.length){console.error('publish blocked: file: deps '+bad.map(([k])=>k).join(', '));process.exit(1)}\""
38
39
  },
39
40
  "dependencies": {
40
41
  "@notrealstudio/nr-json5": "^0.1.1"