@fuzdev/mdz 0.4.0 → 0.5.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 (47) hide show
  1. package/LICENSE +10 -17
  2. package/README.md +2 -3
  3. package/dist/MdzNodeView.svelte +59 -23
  4. package/dist/MdzNodeView.svelte.d.ts.map +1 -1
  5. package/dist/MdzStreamNodeView.svelte +63 -23
  6. package/dist/MdzStreamNodeView.svelte.d.ts.map +1 -1
  7. package/dist/mdz.d.ts +12 -1
  8. package/dist/mdz.d.ts.map +1 -1
  9. package/dist/mdz.js +1 -1
  10. package/dist/mdz_contexts.d.ts +33 -6
  11. package/dist/mdz_contexts.d.ts.map +1 -1
  12. package/dist/mdz_helpers.d.ts +31 -3
  13. package/dist/mdz_helpers.d.ts.map +1 -1
  14. package/dist/mdz_helpers.js +102 -10
  15. package/dist/mdz_lexer.d.ts +3 -1
  16. package/dist/mdz_lexer.d.ts.map +1 -1
  17. package/dist/mdz_lexer.js +89 -15
  18. package/dist/mdz_opcodes.d.ts +3 -1
  19. package/dist/mdz_opcodes.d.ts.map +1 -1
  20. package/dist/mdz_opcodes_to_nodes.d.ts.map +1 -1
  21. package/dist/mdz_opcodes_to_nodes.js +3 -0
  22. package/dist/mdz_stream_parser_link.d.ts.map +1 -1
  23. package/dist/mdz_stream_parser_link.js +81 -9
  24. package/dist/mdz_stream_parser_table.js +8 -1
  25. package/dist/mdz_stream_state.svelte.d.ts +3 -1
  26. package/dist/mdz_stream_state.svelte.d.ts.map +1 -1
  27. package/dist/mdz_stream_state.svelte.js +4 -0
  28. package/dist/mdz_to_svelte.d.ts +17 -4
  29. package/dist/mdz_to_svelte.d.ts.map +1 -1
  30. package/dist/mdz_to_svelte.js +22 -9
  31. package/dist/mdz_token_parser.d.ts.map +1 -1
  32. package/dist/mdz_token_parser.js +16 -2
  33. package/dist/svelte_preprocess_mdz.d.ts +5 -2
  34. package/dist/svelte_preprocess_mdz.d.ts.map +1 -1
  35. package/package.json +7 -7
  36. package/src/lib/mdz.ts +13 -1
  37. package/src/lib/mdz_contexts.ts +36 -6
  38. package/src/lib/mdz_helpers.ts +129 -14
  39. package/src/lib/mdz_lexer.ts +98 -15
  40. package/src/lib/mdz_opcodes.ts +3 -1
  41. package/src/lib/mdz_opcodes_to_nodes.ts +5 -0
  42. package/src/lib/mdz_stream_parser_link.ts +77 -7
  43. package/src/lib/mdz_stream_parser_table.ts +8 -1
  44. package/src/lib/mdz_stream_state.svelte.ts +4 -1
  45. package/src/lib/mdz_to_svelte.ts +57 -13
  46. package/src/lib/mdz_token_parser.ts +16 -2
  47. package/src/lib/svelte_preprocess_mdz.ts +6 -4
@@ -12,7 +12,7 @@
12
12
  * @module
13
13
  */
14
14
 
15
- import type { MdzTableAlign } from './mdz.ts';
15
+ import type { MdzAttribute, MdzTableAlign } from './mdz.ts';
16
16
 
17
17
  /**
18
18
  * Unique monotonic identifier for each node created by the parser.
@@ -66,6 +66,8 @@ export interface MdzOpcodeOpen {
66
66
  level?: 1 | 2 | 3 | 4 | 5 | 6;
67
67
  /** Tag name. Present when `node_type` is `'Element'` or `'Component'`. */
68
68
  name?: string;
69
+ /** Parsed attributes. Present (possibly empty) when `node_type` is `'Element'` or `'Component'`. */
70
+ attributes?: Array<MdzAttribute>;
69
71
  /** Language hint. Present when `node_type` is `'Codeblock'`. */
70
72
  lang?: string | null;
71
73
  /** Whether the list is ordered. Present when `node_type` is `'List'`. */
@@ -20,6 +20,7 @@
20
20
  import { DEV } from 'esm-env';
21
21
 
22
22
  import type {
23
+ MdzAttribute,
23
24
  MdzNode,
24
25
  MdzNodeText,
25
26
  MdzNodeLink,
@@ -42,6 +43,7 @@ interface StackFrame {
42
43
  // metadata from open opcode
43
44
  level?: number;
44
45
  name?: string;
46
+ attributes?: Array<MdzAttribute>;
45
47
  lang?: string | null;
46
48
  ordered?: boolean;
47
49
  start_number?: number;
@@ -77,6 +79,7 @@ export const mdz_opcodes_to_nodes = (opcodes: Array<MdzOpcode>): Array<MdzNode>
77
79
  start: op.start,
78
80
  level: op.level,
79
81
  name: op.name,
82
+ attributes: op.attributes,
80
83
  lang: op.lang,
81
84
  ordered: op.ordered,
82
85
  start_number: op.start_number,
@@ -489,6 +492,7 @@ const build_node = (frame: StackFrame, frame_children: Array<MdzNode>): MdzNode
489
492
  return {
490
493
  type: 'Element',
491
494
  name: frame.name ?? '',
495
+ attributes: frame.attributes ?? [],
492
496
  children: mdz_merge_adjacent_text(frame_children),
493
497
  start: frame.start,
494
498
  end: frame.end!
@@ -498,6 +502,7 @@ const build_node = (frame: StackFrame, frame_children: Array<MdzNode>): MdzNode
498
502
  return {
499
503
  type: 'Component',
500
504
  name: frame.name ?? '',
505
+ attributes: frame.attributes ?? [],
501
506
  children: mdz_merge_adjacent_text(frame_children),
502
507
  start: frame.start,
503
508
  end: frame.end!
@@ -6,8 +6,12 @@
6
6
  */
7
7
 
8
8
  import type { MdzNodeTypeContainer } from './mdz_opcodes.ts';
9
+ import type { MdzAttribute } from './mdz.ts';
9
10
  import {
10
11
  A_UPPER,
12
+ APOSTROPHE,
13
+ DOUBLE_QUOTE,
14
+ EQUALS,
11
15
  LEFT_BRACKET,
12
16
  LEFT_PAREN,
13
17
  MAX_INLINE_NESTING_DEPTH,
@@ -211,11 +215,77 @@ export const try_tag_open = (state: MdzStreamParserState): TryResult => {
211
215
 
212
216
  const name = state.buffer.slice(name_start, i);
213
217
 
214
- // skip whitespace
215
- while (i < state.buffer.length && state.buffer.charCodeAt(i) === SPACE) {
216
- i++;
218
+ // Attribute loop: consume space-separated attributes until `/>` or `>`.
219
+ // This widens the held-tag-open rescan surface (the TODO above) with the
220
+ // attribute/value text — still line-bounded, since a newline anywhere aborts
221
+ // the whole tag (`not_match`). Every buffer-end point holds (`need_more`) so
222
+ // the same tree is produced regardless of where feeds split. The quoted-value
223
+ // terminator search reuses `buffer_index_of` (memoized, append-aware) so a
224
+ // long streamed value doesn't rescan quadratically across held retries,
225
+ // matching `try_complete_link`.
226
+ const attributes: Array<MdzAttribute> = [];
227
+ const seen = new Set<string>();
228
+ for (;;) {
229
+ // skip a run of literal spaces only (tabs/newlines are not SPACE)
230
+ let had_space = false;
231
+ while (i < state.buffer.length && state.buffer.charCodeAt(i) === SPACE) {
232
+ i++;
233
+ had_space = true;
234
+ }
235
+ if (i >= state.buffer.length) return 'need_more';
236
+
237
+ const c = state.buffer.charCodeAt(i);
238
+ // `/>` or `>` ends the open tag — break to the existing self-close / commit
239
+ if (c === SLASH || c === RIGHT_ANGLE) break;
240
+ // otherwise an attribute name must follow, space-separated from what
241
+ // precedes it (`<a x="1"y="2">` — `y` abuts the closing quote — reverts)
242
+ if (!is_letter(c)) return 'not_match';
243
+ if (!had_space) return 'not_match';
244
+
245
+ // collect the attribute name (same grammar as tag names)
246
+ const attr_start = i;
247
+ i++; // first char already passed is_letter
248
+ while (i < state.buffer.length && is_tag_name_char(state.buffer.charCodeAt(i))) {
249
+ i++;
250
+ }
251
+ if (i >= state.buffer.length) return 'need_more'; // mid-name — could still grow
252
+ const attr_name = state.buffer.slice(attr_start, i);
253
+ // duplicate names are ambiguous (Svelte compile-errors) → literal tag
254
+ if (seen.has(attr_name)) return 'not_match';
255
+ seen.add(attr_name);
256
+
257
+ let value: string | true = true; // bare name is boolean `true`
258
+ if (state.buffer.charCodeAt(i) === EQUALS) {
259
+ i++; // consume =
260
+ if (i >= state.buffer.length) return 'need_more'; // nothing after `=` yet
261
+ // the value must be immediately quoted — no space, no `{`, no unquoted
262
+ const quote_code = state.buffer.charCodeAt(i);
263
+ if (quote_code !== DOUBLE_QUOTE && quote_code !== APOSTROPHE) return 'not_match';
264
+ i++; // consume opening quote
265
+ const value_start = i;
266
+ // find the matching quote and the next newline via the memoized,
267
+ // append-aware search; a `>` inside the value is content, consumed
268
+ // atomically here so a `</Name>`-shaped substring never reaches
269
+ // `try_close_tag`
270
+ const quote_str = quote_code === DOUBLE_QUOTE ? '"' : "'";
271
+ const q = buffer_index_of(state, quote_str, value_start);
272
+ const n = buffer_index_of(state, '\n', value_start);
273
+ if (q !== -1 && (n === -1 || q < n)) {
274
+ value = state.buffer.slice(value_start, q);
275
+ i = q + 1; // past the closing quote
276
+ } else if (n !== -1 && (q === -1 || n < q)) {
277
+ return 'not_match'; // newline inside the value → literal tag
278
+ } else {
279
+ return 'need_more'; // neither found yet — unterminated so far, hold
280
+ }
281
+ }
282
+ attributes.push({
283
+ name: attr_name,
284
+ value,
285
+ start: offset(state, attr_start),
286
+ end: offset(state, i)
287
+ });
217
288
  }
218
- if (i >= state.buffer.length) return 'need_more';
219
289
 
220
290
  // check for self-closing />
221
291
  if (state.buffer.charCodeAt(i) === SLASH && i + 1 >= state.buffer.length) {
@@ -230,7 +300,7 @@ export const try_tag_open = (state: MdzStreamParserState): TryResult => {
230
300
  ensure_paragraph(state);
231
301
  const id = alloc_id(state);
232
302
  const tag_start = offset(state, start);
233
- emit(state, { type: 'open', id, node_type, start: tag_start, name });
303
+ emit(state, { type: 'open', id, node_type, start: tag_start, name, attributes });
234
304
  emit(state, { type: 'close', id, end: offset(state, i + 2) });
235
305
  state.active_text_id = null;
236
306
  state.pos = i + 2;
@@ -253,8 +323,8 @@ export const try_tag_open = (state: MdzStreamParserState): TryResult => {
253
323
  ensure_paragraph(state);
254
324
  const id = alloc_id(state);
255
325
  const tag_start = offset(state, start);
256
- const delimiter = state.buffer.slice(start, i); // e.g., "<Alert>"
257
- emit(state, { type: 'open', id, node_type, start: tag_start, name });
326
+ const delimiter = state.buffer.slice(start, i); // e.g., "<Alert>" or "<Alert x="1">"
327
+ emit(state, { type: 'open', id, node_type, start: tag_start, name, attributes });
258
328
  push_stack_entry(state, id, node_type, tag_start, delimiter, name);
259
329
  state.active_text_id = null;
260
330
  state.pos = i;
@@ -402,7 +402,14 @@ const emit_inline_nodes = (state: MdzStreamParserState, nodes: Array<MdzNode>):
402
402
  case 'Component': {
403
403
  const id = alloc_id(state);
404
404
  const s = offset(state, node.start);
405
- emit(state, { type: 'open', id, node_type: node.type, start: s, name: node.name });
405
+ emit(state, {
406
+ type: 'open',
407
+ id,
408
+ node_type: node.type,
409
+ start: s,
410
+ name: node.name,
411
+ attributes: node.attributes
412
+ });
406
413
  push_stack_entry(state, id, node.type, s, '', node.name);
407
414
  emit_inline_nodes(state, node.children);
408
415
  pop_stack_entry(state);
@@ -11,7 +11,7 @@
11
11
  import { DEV } from 'esm-env';
12
12
 
13
13
  import type { MdzNodeId, MdzNodeType, MdzOpcode } from './mdz_opcodes.ts';
14
- import type { MdzTableAlign } from './mdz.ts';
14
+ import type { MdzAttribute, MdzTableAlign } from './mdz.ts';
15
15
 
16
16
  /**
17
17
  * A reactive node in the stream renderer tree.
@@ -27,6 +27,8 @@ export class MdzStreamNode {
27
27
  reference?: string = $state();
28
28
  link_type?: 'external' | 'internal' = $state();
29
29
  name?: string;
30
+ /** Parsed tag attributes (Element/Component nodes); known at open, never mutated, so plain not `$state`. */
31
+ attributes?: Array<MdzAttribute>;
30
32
  lang?: string | null;
31
33
  heading_id?: string = $state();
32
34
  ordered?: boolean;
@@ -105,6 +107,7 @@ export class MdzStreamState {
105
107
  const node = new MdzStreamNode(opcode.id, opcode.node_type);
106
108
  if (opcode.level !== undefined) node.level = opcode.level;
107
109
  if (opcode.name !== undefined) node.name = opcode.name;
110
+ if (opcode.attributes !== undefined) node.attributes = opcode.attributes;
108
111
  if (opcode.lang !== undefined) node.lang = opcode.lang;
109
112
  if (opcode.ordered !== undefined) node.ordered = opcode.ordered;
110
113
  if (opcode.start_number !== undefined) node.start_number = opcode.start_number;
@@ -13,7 +13,34 @@ import { escape_svelte_text } from '@fuzdev/fuz_util/svelte_preprocess_helpers.t
13
13
  import { escape_js_string } from '@fuzdev/fuz_util/string.ts';
14
14
 
15
15
  import type { MdzNode, MdzNodeTableRow } from './mdz.ts';
16
- import { mdz_is_void_element, mdz_classify_link } from './mdz_helpers.ts';
16
+ import type { MdzComponentProps } from './mdz_contexts.ts';
17
+ import {
18
+ mdz_is_void_element,
19
+ mdz_classify_link,
20
+ mdz_filter_element_attributes,
21
+ mdz_filter_component_attributes,
22
+ ALLOWED_ELEMENT_ATTRIBUTES
23
+ } from './mdz_helpers.ts';
24
+
25
+ // emits parsed attributes as Svelte markup, matching the file's `name={'...'}`
26
+ // idiom for string attrs. Booleans emit as `name={true}`, not bare — a bare
27
+ // attribute compiles to `name=""`, while the runtime views spread `true`, which
28
+ // Svelte renders per attribute (`aria-hidden="true"`); the expression form
29
+ // keeps precompiled and runtime output identical. Each entry gets a leading
30
+ // space, so the result splices straight after the tag name.
31
+ /**
32
+ * A build-time component registry entry: an import path, or an import path
33
+ * plus the attribute names passed as props (see `MdzComponentProps`).
34
+ */
35
+ export type MdzComponentImport = string | { path: string; props?: MdzComponentProps };
36
+
37
+ const format_attributes = (entries: Array<[string, string | true]>): string => {
38
+ let out = '';
39
+ for (const [name, value] of entries) {
40
+ out += value === true ? ` ${name}={true}` : ` ${name}={'${escape_js_string(value)}'}`;
41
+ }
42
+ return out;
43
+ };
17
44
 
18
45
  /**
19
46
  * Result of converting `MdzNode` arrays to Svelte markup.
@@ -38,10 +65,13 @@ const component_local_name = (import_path: string): string =>
38
65
  */
39
66
  export interface MdzToSvelteOptions {
40
67
  /**
41
- * Component name to import path mapping (e.g., `{Alert: '#lib/Alert.svelte'}`).
68
+ * Component name to import mapping, mirroring the runtime `MdzComponents`
69
+ * registry: a bare import path receives no attributes
70
+ * (`{Note: '#lib/Note.svelte'}`), and the config form names the attributes
71
+ * passed as props (`{Alert: {path: '#lib/Alert.svelte', props: ['status']}}`).
42
72
  * If content references a component not in this map, `has_unconfigured_tags` is set.
43
73
  */
44
- components: Record<string, string>;
74
+ components: Record<string, MdzComponentImport>;
45
75
 
46
76
  /**
47
77
  * Allowed HTML elements (e.g., `{aside: true, details: true}`).
@@ -53,8 +83,9 @@ export interface MdzToSvelteOptions {
53
83
 
54
84
  /**
55
85
  * Base path for resolving relative links (e.g., `'/docs/usage/'`).
56
- * When provided, relative references (`./`, `../`) are resolved to absolute paths
57
- * and passed through `resolve()`. Trailing slash recommended.
86
+ * When provided, relative references (`./`, `../`) are resolved against it and,
87
+ * like absolute references, passed through `resolve()` in its pathname form.
88
+ * Trailing slash recommended.
58
89
  */
59
90
  base?: string;
60
91
 
@@ -140,7 +171,7 @@ export const mdz_to_svelte = (
140
171
  if (link.kind === 'unsafe') return children_markup;
141
172
  if (link.kind === 'resolve') {
142
173
  imports.set('resolve', { path: '$app/paths', kind: 'named' });
143
- return `<a href={resolve('${escape_js_string(link.href)}')}>${children_markup}</a>`;
174
+ return `<a href={resolve('${escape_js_string(link.path)}')}>${children_markup}</a>`;
144
175
  }
145
176
  if (link.kind === 'external') {
146
177
  return `<a href={'${escape_js_string(link.href)}'} target="_blank" rel="noopener">${children_markup}</a>`;
@@ -208,25 +239,38 @@ export const mdz_to_svelte = (
208
239
  }
209
240
 
210
241
  case 'Element': {
211
- if (elements[node.name] === undefined) {
242
+ if (elements[node.name] !== true) {
212
243
  has_unconfigured_tags = true;
213
244
  return '';
214
245
  }
246
+ // filter via the SAME helper the node views use, so precompiled and
247
+ // runtime output stay identical (its DEV-warn fires at build time too)
248
+ const attrs_str = format_attributes(
249
+ Object.entries(
250
+ mdz_filter_element_attributes(node.name, node.attributes, ALLOWED_ELEMENT_ATTRIBUTES)
251
+ )
252
+ );
215
253
  if (mdz_is_void_element(node.name)) {
216
254
  // void elements cannot have content — any parsed children are dropped
217
- return `<${node.name} />`;
255
+ return `<${node.name}${attrs_str} />`;
218
256
  }
219
- return `<${node.name}>${render_nodes(node.children)}</${node.name}>`;
257
+ return `<${node.name}${attrs_str}>${render_nodes(node.children)}</${node.name}>`;
220
258
  }
221
259
 
222
260
  case 'Component': {
223
- const import_path = components[node.name];
224
- if (!import_path) {
261
+ const entry = components[node.name];
262
+ if (!entry) {
225
263
  has_unconfigured_tags = true;
226
264
  return '';
227
265
  }
228
- imports.set(node.name, { path: import_path, kind: 'default' });
229
- return `<${node.name}>${render_nodes(node.children)}</${node.name}>`;
266
+ const { path, props } =
267
+ typeof entry === 'string' ? { path: entry, props: undefined } : entry;
268
+ imports.set(node.name, { path, kind: 'default' });
269
+ // filter via the SAME helper the node views use
270
+ const attrs_str = format_attributes(
271
+ Object.entries(mdz_filter_component_attributes(node.name, node.attributes, props))
272
+ );
273
+ return `<${node.name}${attrs_str}>${render_nodes(node.children)}</${node.name}>`;
230
274
  }
231
275
 
232
276
  default:
@@ -581,7 +581,14 @@ export class MdzTokenParser {
581
581
  const t = this.#tokens[this.#index]!;
582
582
  if (t.type === 'tag_close' && t.name === tag_name) {
583
583
  this.#index++;
584
- return { type: node_type, name: tag_name, children, start, end: t.end };
584
+ return {
585
+ type: node_type,
586
+ name: tag_name,
587
+ attributes: open_token.attributes,
588
+ children,
589
+ start,
590
+ end: t.end
591
+ };
585
592
  }
586
593
  const node = this.#parse_inline();
587
594
  if (node) mdz_push_merging_text(children, node);
@@ -595,7 +602,14 @@ export class MdzTokenParser {
595
602
  const token = this.#tokens[this.#index]! as MdzTokenTagSelfClose;
596
603
  const node_type: 'Component' | 'Element' = token.is_component ? 'Component' : 'Element';
597
604
  this.#index++;
598
- return { type: node_type, name: token.name, children: [], start: token.start, end: token.end };
605
+ return {
606
+ type: node_type,
607
+ name: token.name,
608
+ attributes: token.attributes,
609
+ children: [],
610
+ start: token.start,
611
+ end: token.end
612
+ };
599
613
  }
600
614
 
601
615
  // -- Paragraph flushing --
@@ -37,7 +37,7 @@ import {
37
37
  } from '@fuzdev/fuz_util/svelte_preprocess_helpers.ts';
38
38
 
39
39
  import { mdz_parse } from './mdz.ts';
40
- import { mdz_to_svelte, type MdzToSvelteResult } from './mdz_to_svelte.ts';
40
+ import { mdz_to_svelte, type MdzComponentImport, type MdzToSvelteResult } from './mdz_to_svelte.ts';
41
41
 
42
42
  /**
43
43
  * An estree `ImportDeclaration` augmented with Svelte's position data.
@@ -56,12 +56,14 @@ export interface SveltePreprocessMdzOptions {
56
56
  /**
57
57
  * Component import mapping for mdz content.
58
58
  * Key: component name as used in mdz (e.g., 'Alert').
59
- * Value: import path (e.g., '#lib/Alert.svelte').
59
+ * Value: import path (e.g., '#lib/Alert.svelte'), which receives no attributes,
60
+ * or `{path, props}` naming the attributes passed as props
61
+ * (e.g., `{path: '#lib/Alert.svelte', props: ['status']}`, or `props: 'all'`).
60
62
  *
61
63
  * If mdz content references a component not in this map,
62
64
  * that `Mdz` usage is skipped (left as runtime).
63
65
  */
64
- components?: Record<string, string>;
66
+ components?: Record<string, MdzComponentImport>;
65
67
 
66
68
  /**
67
69
  * Allowed HTML elements in mdz content (e.g., `{aside: true, details: true}`).
@@ -235,7 +237,7 @@ interface FindMdzUsagesResult {
235
237
  }
236
238
 
237
239
  interface FindMdzUsagesContext {
238
- components: Record<string, string>;
240
+ components: Record<string, MdzComponentImport>;
239
241
  elements: Record<string, boolean>;
240
242
  filename: string | undefined;
241
243
  source: string;