@entropicwarrior/sdoc 0.1.18 → 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.
@@ -4,7 +4,7 @@
4
4
  {
5
5
  type: skill
6
6
 
7
- sdoc-version: 0.1
7
+ sdoc-version: 0.2
8
8
  }
9
9
 
10
10
  # About @about
@@ -178,7 +178,7 @@ Content of Section B.
178
178
 
179
179
  **Important:** \`{[.]}\` with the closing brace on the same line creates an empty list — the brace closes the block immediately. Always put the closing \`}\` on a separate line after the items.
180
180
 
181
- Implicit lists only work for bullet lists (\`-\`) where every item is a single line. For numbered lists, always use the explicit \`{[#]\` block form.
181
+ Implicit lists work for both bullet (\`-\`) and numbered (\`1.\`, \`2.\`, etc.) items. Each item must be a single line. For multi-line item titles or item body content, use the explicit \`{[.]}\` or \`{[#]\` block form.
182
182
 
183
183
  **Task lists** — checkbox syntax inside explicit list blocks:
184
184
 
@@ -244,6 +244,27 @@ Content of Section B.
244
244
  ```
245
245
 
246
246
  \`borderless\` removes borders and row striping. \`headerless\` treats all rows as data (no header). Flags combine in any order.
247
+
248
+ Width and alignment flags control table sizing and position:
249
+
250
+ ```
251
+ {[table 60% center]
252
+ Endpoint | Status
253
+ /v2/weather | Active
254
+ }
255
+
256
+ {[table auto]
257
+ Key | Value
258
+ Version | 2.0
259
+ }
260
+
261
+ {[table 400px right borderless]
262
+ v2.0 | Current
263
+ v1.0 | Deprecated
264
+ }
265
+ ```
266
+
267
+ Width: \`auto\` (shrink to content), \`NN%\` (percentage), or \`NNpx\` (pixels). Default is 100%. Alignment: \`left\` (default), \`center\`, or \`right\`. All flags compose freely in any order.
247
268
  }
248
269
 
249
270
  # Inline Formatting @inline-formatting
@@ -279,7 +300,7 @@ Content of Section B.
279
300
  ![A](a.png =48%) ![B](b.png =48%)
280
301
  ```
281
302
 
282
- Autolinks: \`\<https://example.com\>\`
303
+ Autolinks: \`\<https://example.com\>\` — angle brackets are optional; bare URLs starting with \`http://\`, \`https://\`, or \`mailto:\` are also auto-linked.
283
304
 
284
305
  Math: Use \`\$...\$\` for inline math and \`\$\$...\$\$\` for display math. Use \`\\\`\\\`\\\`math\` code fences for multi-line equations. A plain \`\$\` followed by a digit (e.g. \`\$100\`) does not trigger math mode.
285
306
  }
@@ -380,7 +401,7 @@ Content of Section B.
380
401
  @meta {
381
402
  type: doc
382
403
 
383
- sdoc-version: 0.1
404
+ sdoc-version: 0.2
384
405
 
385
406
  company: Irreversible Inc.
386
407
 
@@ -420,7 +441,7 @@ Content of Section B.
420
441
 
421
442
  \`tags: tag1, tag2\` — comma-separated tags.
422
443
 
423
- \`sdoc-version: 0.1\` — SDOC format version. A parser warning is
444
+ \`sdoc-version: 0.2\` — SDOC format version. A parser warning is
424
445
  emitted when this key is missing from \`@meta\`.
425
446
  }
426
447
 
@@ -441,7 +462,7 @@ Content of Section B.
441
462
  {
442
463
  Backslash escapes special characters: \`\\\\\` \`\\{\` \`\\}\` \`\\@\`
443
464
  \`\\[\` \`\\]\` \`\\(\` \`\\)\` \`\\*\` \`\\~\` \`\\#\` \`\\!\` \`\\\<\`
444
- \`\\\>\` \`\\\$\` \`\\+\` \`\\=\` \`\\-\` \`\\^\`
465
+ \`\\\>\` \`\\\$\` \`\\+\` \`\\=\` \`\\-\` \`\\^\` \`\\?\`
445
466
 
446
467
  A line starting with \`\\#\` renders as a literal \`#\` (not a heading). Use \`\\\$\` to prevent a dollar sign from starting math mode.
447
468
  }
@@ -454,6 +475,89 @@ Content of Section B.
454
475
  - **Commas:** commas between list items or scopes are allowed but ignored — use them if you find them readable.
455
476
  }
456
477
  }
478
+
479
+ # Scope Types @scope-types
480
+ {
481
+ A \`:type\` annotation on a heading gives the scope semantic meaning. Place it after the optional \`@id\`:
482
+
483
+ ```
484
+ # User Authentication @auth :requirement
485
+ {
486
+ The system shall authenticate users via OAuth 2.0.
487
+ }
488
+
489
+ # OAuth Flow :specification @oauth-flow
490
+ {
491
+ Implements @auth using the authorization code flow.
492
+ }
493
+
494
+ # Deprecation Notice :warning
495
+ {
496
+ This API will be removed in v3.0.
497
+ }
498
+ ```
499
+
500
+ {[.]
501
+ - Syntax: \`# Title @id :type\` or \`# Title :type @id\` or \`# Title :type\` — both orderings work
502
+ - The colon requires whitespace before it: \`# Note: Important\` is NOT a type — the colon is part of the title
503
+ - Any string is valid. Well-known types: \`schema\`, \`example\`, \`requirement\`, \`specification\`, \`definition\`, \`note\`, \`warning\`, \`test\`, \`task\`, \`api\`, \`config\`, \`deprecated\`, \`comment\`
504
+ - Works with K&R style: \`# Title :warning {\`
505
+ }
506
+
507
+ The special type \`:comment\` makes a scope that is in the AST but not rendered — useful for editorial notes and AI agent instructions:
508
+
509
+ ```
510
+ # TODO :comment
511
+ {
512
+ Rewrite this section after the API stabilises.
513
+ }
514
+ ```
515
+ }
516
+
517
+ # Data Blocks @data-blocks
518
+ {
519
+ Add \`:data\` to a JSON code fence to have the parser parse the content into structured data on the AST node:
520
+
521
+ `````
522
+ ```json :data
523
+ {
524
+ "name": "SDOC",
525
+ "version": "0.2",
526
+ "features": ["scopes", "lists", "tables"]
527
+ }
528
+ ```
529
+ `````
530
+
531
+ {[.]
532
+ - Syntax: \` \`\`\`json :data \` — the \`:data\` flag follows the language tag
533
+ - Invalid JSON produces a parse error
534
+ - JSON-only in v0.2
535
+ - Without \`:data\`, JSON code blocks remain raw text (existing behaviour)
536
+ }
537
+ }
538
+
539
+ # Comments @comments
540
+ {
541
+ **Line comments** — \`//\` at the start of a line (after optional indentation) skips the line entirely. Not in the AST, not rendered:
542
+
543
+ ```
544
+ # Config @config
545
+ {
546
+ // TODO: add validation
547
+ The config file uses JSON format.
548
+
549
+ // hidden from output
550
+ See @setup for details.
551
+ }
552
+ ```
553
+
554
+ {[.]
555
+ - Mid-line \`//\` has no effect — URLs like \`https://example.com\` are safe
556
+ - Inside code blocks: \`//\` has no special meaning
557
+ }
558
+
559
+ **Comment scopes** — use the \`:comment\` scope type for structured non-rendered content (see @scope-types above).
560
+ }
457
561
  }
458
562
 
459
563
  # Common Mistakes @common-mistakes
@@ -1,19 +1,20 @@
1
- # SDOC Specification v0.1 @sdoc-spec
1
+ # SDOC Specification v0.2 @sdoc-spec
2
2
  {
3
3
  # Meta @meta
4
4
  {
5
5
  type: doc
6
6
 
7
- sdoc-version: 0.1
7
+ sdoc-version: 0.2
8
8
  }
9
9
 
10
10
  # About @about
11
11
  {
12
- The formal SDOC v0.1 specification. Defines syntax for scopes,
13
- lists, tables, code blocks, inline formatting, references, and
14
- the meta scope. Includes the formal EBNF grammar. Read for
15
- edge cases and parser behaviour questions. For a friendlier
16
- user-facing reference, see \`docs/reference/syntax.sdoc\`.
12
+ The formal SDOC v0.2 specification. Defines syntax for scopes,
13
+ lists, tables, code blocks, inline formatting, references,
14
+ the meta scope, scope types, data blocks, and comments.
15
+ Includes the formal EBNF grammar. Read for edge cases and
16
+ parser behaviour questions. For a friendlier user-facing
17
+ reference, see \`docs/reference/syntax.sdoc\`.
17
18
  }
18
19
 
19
20
  # Overview @overview
@@ -51,12 +52,17 @@
51
52
  {
52
53
  ```
53
54
  # Title text @id
55
+ # Title text @id :type
56
+ # Title text :type @id
57
+ # Title text :type
54
58
  ```
55
59
 
56
60
  {[.]
57
61
  - The line must start with `#` (after optional indentation)
58
62
  - Multiple `#` characters are allowed but do not affect depth. Depth comes only from scope nesting
59
- - The optional `@id` must appear at the end of the line, separated by whitespace
63
+ - The optional `@id` must appear at the end of the line (or before `:type`), separated by whitespace
64
+ - The optional `:type` assigns a scope type (see @scope-types). Both `@id :type` and `:type @id` orderings are valid
65
+ - `:type` requires whitespace before the colon, so `# Note: Important` is NOT a scope type — the colon is part of the title
60
66
  - If no `@id` is present, the scope has no ID
61
67
  - If you need a literal `@` in the title, escape it (`\@`)
62
68
  }
@@ -122,7 +128,7 @@ Content of Section B.
122
128
  - The opener must be the last token on the line (trailing whitespace is allowed)
123
129
  - Applies to `{`, `{[.]`, `{[#]`, `{[table]`, and `{[table <flags>]`
124
130
  - Also works on list-item shorthand lines (e.g., `- Item {`)
125
- - Table options (e.g., `{[table borderless]`) work in K&R style: `# Data {[table borderless]`
131
+ - Table options (e.g., `{[table borderless]`, `{[table 60% center]`) work in K&R style: `# Data {[table borderless]`
126
132
  - Escaped braces (`\{`) are not treated as openers
127
133
  - The closing `}` must still appear on its own line
128
134
  - Inline blocks (`{ content }`) are not affected; a line ending with `}` is not treated as K&R
@@ -344,9 +350,33 @@ Content of Section B.
344
350
  {[.]
345
351
  - `borderless` removes all table borders and row striping
346
352
  - `headerless` treats the first row as data (no header row)
347
- - Flags can be combined in any order
353
+ - `auto` sets width to shrink-to-content
354
+ - A percentage value (e.g. `60%`, `33.3%`) sets an explicit percentage width
355
+ - A pixel value (e.g. `400px`) sets an explicit pixel width
356
+ - `center` centers the table (auto margins)
357
+ - `right` right-aligns the table (margin-left: auto)
358
+ - `left` left-aligns the table (default, no extra styles)
359
+ - Default width is 100%; default alignment is left
360
+ - All flags can be combined in any order
348
361
  - Works with both Allman and K&R brace styles
349
362
  }
363
+
364
+ ```
365
+ {[table 60% center]
366
+ Endpoint | Status
367
+ /v2/weather | Active
368
+ }
369
+
370
+ {[table auto right borderless]
371
+ Key | Value
372
+ Version | 2.0
373
+ }
374
+
375
+ {[table 400px]
376
+ Name | Age
377
+ Alice | 30
378
+ }
379
+ ```
350
380
  }
351
381
  }
352
382
 
@@ -387,6 +417,8 @@ Content of Section B.
387
417
  ```
388
418
 
389
419
  Only `http`, `https`, and `mailto` schemes are recognised.
420
+
421
+ Bare URLs starting with `http://`, `https://`, or `mailto:` are also auto-linked without angle brackets.
390
422
  }
391
423
 
392
424
  # Images @images
@@ -466,7 +498,7 @@ Content of Section B.
466
498
 
467
499
  # Escaping @escaping
468
500
  {
469
- In normal text (including headings and paragraphs), a backslash escapes: `\\` `\{` `\}` `\@` `\[` `\]` `\(` `\)` `\*` `\~` `\#` `\!` `\<` `\>` `\$` `\+` `\=` `\-` `\^` and `` \` ``.
501
+ In normal text (including headings and paragraphs), a backslash escapes: `\\` `\{` `\}` `\@` `\[` `\]` `\(` `\)` `\*` `\~` `\#` `\!` `\<` `\>` `\$` `\+` `\=` `\-` `\^` `\?` and `` \` ``.
470
502
 
471
503
  Escapes are processed before reference detection.
472
504
 
@@ -516,6 +548,115 @@ Content of Section B.
516
548
  }
517
549
  }
518
550
  }
551
+
552
+ # Scope Types @scope-types
553
+ {
554
+ A scope type annotation provides semantic meaning to a scope. The type is specified with `:typename` on the heading line, after optional `@id`:
555
+
556
+ ```
557
+ # User Authentication @auth :requirement
558
+ {
559
+ The system shall authenticate users via OAuth 2.0.
560
+ }
561
+
562
+ # OAuth Flow :specification @oauth-flow
563
+ {
564
+ Implements @auth using the authorization code flow.
565
+ }
566
+
567
+ # Important :warning
568
+ {
569
+ This API is deprecated and will be removed in v3.0.
570
+ }
571
+ ```
572
+
573
+ {[.]
574
+ - Syntax: `# Title @id :type` or `# Title :type @id` or `# Title :type` — both orderings of `@id` and `:type` are supported
575
+ - The colon in `:type` requires whitespace before it, so `# Note: Important` is NOT a scope type — the colon is part of the title text
576
+ - Any string is valid as a type name
577
+ - Well-known types: `schema`, `example`, `requirement`, `specification`, `definition`, `note`, `warning`, `test`, `task`, `api`, `config`, `deprecated`, `comment`
578
+ - The type is stored on the AST node and available to renderers and tooling
579
+ - Scope types enable AI agents to filter and navigate by semantic meaning (e.g., "show all requirements", "find tests for this spec")
580
+ - Works with K&R brace style: `# Title :warning {`
581
+ }
582
+
583
+ # Comment Scopes @comment-scopes
584
+ {
585
+ The `:comment` scope type creates a scope that is present in the AST but not rendered in the document output. This is useful for editorial annotations, AI agent instructions, and internal notes:
586
+
587
+ ```
588
+ # TODO :comment
589
+ {
590
+ Rewrite this section after the API stabilises.
591
+ @alice please review the error handling.
592
+ }
593
+
594
+ # Agent Instructions :comment
595
+ {
596
+ When summarising this document, focus on the
597
+ requirements and skip the implementation details.
598
+ }
599
+ ```
600
+
601
+ {[.]
602
+ - Comment scopes use the standard scope type system — no special syntax beyond `:comment`
603
+ - The scope is parsed into the AST (agents and tooling can extract it)
604
+ - The scope is not rendered in HTML, PDF, or other visual outputs
605
+ - Comment scopes can contain any valid SDOC content (paragraphs, lists, code blocks, nested scopes)
606
+ - Useful for structured annotations that are too complex for line comments
607
+ }
608
+ }
609
+ }
610
+
611
+ # Data Blocks @data-blocks
612
+ {
613
+ A code fence with the `:data` flag causes the parser to parse the content as structured data and attach it to the AST node:
614
+
615
+ `````
616
+ ```json :data
617
+ {
618
+ "name": "SDOC",
619
+ "version": "0.2",
620
+ "features": ["scopes", "lists", "tables"]
621
+ }
622
+ ```
623
+ `````
624
+
625
+ {[.]
626
+ - Syntax: ` ```json :data ` — the `:data` flag follows the language tag on the opening fence line
627
+ - The parser parses the JSON content and stores the result on the AST node
628
+ - Invalid JSON produces a parse error
629
+ - JSON is the only supported data format in v0.2
630
+ - Without the `:data` flag, JSON code blocks are treated as raw text (existing behaviour)
631
+ - Data blocks render visually the same as regular code blocks, but the parsed data is available to tooling and agents
632
+ }
633
+ }
634
+
635
+ # Line Comments @line-comments
636
+ {
637
+ A line starting with `//` (after optional indentation) is a line comment. The line is skipped entirely — it does not appear in the AST and is not rendered:
638
+
639
+ ```
640
+ # Configuration @config
641
+ {
642
+ // TODO: add validation rules
643
+ The config file uses JSON format.
644
+
645
+ // This paragraph is hidden from output
646
+ // but visible in the source file.
647
+
648
+ See @setup for installation steps.
649
+ }
650
+ ```
651
+
652
+ {[.]
653
+ - The `//` must be at the start of the line (after optional whitespace)
654
+ - Comment lines are discarded during parsing — they are not present in the AST
655
+ - Inside code blocks: `//` has no special meaning (code block content is raw)
656
+ - Mid-line `//` has no special meaning — URLs like `https://example.com` are safe
657
+ - Use line comments for quick annotations; use comment scopes (@comment-scopes) for structured non-rendered content
658
+ }
659
+ }
519
660
  }
520
661
 
521
662
  # Styles, Header, and Footer @styling
@@ -585,7 +726,7 @@ Content of Section B.
585
726
  date: 2026-02-09
586
727
  version: 1.0
587
728
  status: Draft
588
- sdoc-version: 0.1
729
+ sdoc-version: 0.2
589
730
  }
590
731
  ```
591
732
 
@@ -593,7 +734,7 @@ Content of Section B.
593
734
  - Key matching is case-insensitive
594
735
  - The pattern requires at least one space after the colon (`key: value`, not `key:value`)
595
736
  - Well-known keys: `style`, `styleappend`/`style-append`, `header`, `footer`, `sdoc-version`
596
- - `sdoc-version` identifies the SDOC format version the document targets (current: `0.1`). A parser warning is emitted when this key is missing.
737
+ - `sdoc-version` identifies the SDOC format version the document targets (current: `0.2`). A parser warning is emitted when this key is missing.
597
738
  - All other keys are stored as custom properties (e.g., `author`, `date`, `version`, `status`, `tags`)
598
739
  - Sub-scope syntax takes precedence: if both `# Style { path }` and `style: path` exist, the sub-scope value wins
599
740
  - Key:value and sub-scope syntax can be mixed freely in the same meta scope
@@ -713,6 +854,7 @@ Content of Section A.
713
854
  - `>` blockquote line
714
855
  - `---` / `***` / `___` horizontal rule
715
856
  - `` ``` `` code fence
857
+ - `//` line comment (discarded, not in AST)
716
858
  }
717
859
 
718
860
  Blank lines are allowed anywhere and are ignored.
@@ -731,25 +873,34 @@ Content of Section A.
731
873
  scope = heading ws? block
732
874
  | heading ws? braceless_body
733
875
  | heading_with_opener block_body "}" ;
734
- heading = "#" { "#" } ws title (ws id)? ;
735
- heading_with_opener = "#" { "#" } ws title (ws id)? ws block_opener ;
876
+ heading = "#" { "#" } ws title
877
+ ((ws id)? (ws scope_type)? | (ws scope_type)? (ws id)?) ;
878
+ heading_with_opener = "#" { "#" } ws title
879
+ ((ws id)? (ws scope_type)? | (ws scope_type)? (ws id)?) ws block_opener ;
736
880
  id = "@" ident ;
881
+ scope_type = ":" ident ; (* id and scope_type may appear in either order *)
737
882
  block_opener = "{" | "{[.]" | "{[#]" | table_open ;
738
883
  block = "{" ws? block_body "}" ;
739
- braceless_body = { paragraph | code_block | blockquote | implicit_list
740
- | horizontal_rule | headingless_scope | table_scope
741
- | blank } ;
742
-
743
- block_body = { blank | paragraph | scope | headingless_scope
744
- | list_scope | table_scope
884
+ braceless_body = { paragraph | code_block | data_block | blockquote
885
+ | implicit_list | horizontal_rule | headingless_scope
886
+ | list_scope | table_scope | bare_directive
887
+ | line_comment | blank } ;
888
+
889
+ bare_directive = "@" ("meta" | "about") (ws block | braceless_body) ;
890
+ block_body = { blank | line_comment | paragraph | scope
891
+ | headingless_scope | list_scope | table_scope
745
892
  | implicit_list | blockquote | horizontal_rule
746
- | code_block | comma_sep } ;
893
+ | code_block | data_block | bare_directive | comma_sep } ;
747
894
  headingless_scope = "{" ws? block_body "}" ;
748
895
  list_scope = list_open ws? list_body "}" ;
749
896
  list_open = "{[.]" | "{[#]" ;
750
897
  table_scope = table_open ws? table_body "}" ;
751
898
  table_open = "{[table" { ws table_flag } "]" ;
752
- table_flag = "borderless" | "headerless" ;
899
+ table_flag = "borderless" | "headerless"
900
+ | "auto" | percentage | pixels
901
+ | "left" | "center" | "right" ;
902
+ percentage = digit { digit } [ "." digit { digit } ] "%" ;
903
+ pixels = digit { digit } "px" ;
753
904
  table_body = table_row { table_row } ;
754
905
  table_row = cell { "|" cell } ;
755
906
  list_body = { blank | comma_sep | scope | list_item_shorthand
@@ -767,15 +918,19 @@ Content of Section A.
767
918
  paragraph = text_line { ws? text_line } ;
768
919
  text_line = line_not_starting_with_command ;
769
920
 
770
- blockquote = quote_line { quote_line | blank } ;
921
+ blockquote = quote_line { quote_line } ;
771
922
  quote_line = ">" text_line ;
772
923
 
773
924
  horizontal_rule = "---" | "***" | "___" ;
774
925
 
775
926
  code_block = fence_open raw_text fence_close ;
927
+ data_block = data_fence_open raw_text fence_close ;
776
928
  fence_open = "```" [lang] [ws "src:" path] [ws "lines:" range] newline ;
929
+ data_fence_open = "```" lang ws ":data" newline ;
777
930
  fence_close = "```" newline ;
778
931
 
932
+ line_comment = "//" { any_char } newline ; (* discarded, not in AST *)
933
+
779
934
  comma_sep = "," ;
780
935
  blank = newline ;
781
936
 
@@ -783,7 +938,10 @@ Content of Section A.
783
938
  ```
784
939
 
785
940
  {[.]
786
- - `title` is the remainder of the heading line, excluding the optional trailing `@id`
941
+ - `title` is the remainder of the heading line, excluding the optional trailing `@id` and `:type`
942
+ - `scope_type` and `id` may appear in either order after the title
943
+ - `line_comment` lines are discarded during parsing and do not appear in the AST
944
+ - `data_block` content is parsed as JSON; invalid JSON produces a parse error
787
945
  - If a line starts with a command token, it is not a paragraph line
788
946
  - The grammar is line-oriented; practical parsers should operate on lines
789
947
  }
@@ -792,10 +950,10 @@ Content of Section A.
792
950
  # Open Questions @open-questions
793
951
  {
794
952
  {[.]
795
- - Comment syntax (if any)
796
953
  - Duplicate ID resolution (error vs warning vs nearest-scope)
797
- - Additional list types (checkboxes, alpha, roman)
954
+ - Additional list types (alpha, roman)
798
955
  - Additional inline formatting (underline)
956
+ - Additional data block formats beyond JSON (YAML, TOML)
799
957
  }
800
958
  }
801
959
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@entropicwarrior/sdoc",
3
3
  "displayName": "SDOC",
4
4
  "description": "A plain-text documentation format with explicit brace scoping — deterministic parsing, AI-agent efficiency, and 10-50x token savings vs Markdown.",
5
- "version": "0.1.18",
5
+ "version": "0.2.0",
6
6
  "publisher": "entropicwarrior",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -246,6 +246,7 @@ function renderNotionBlocks(nodes) {
246
246
  // Unwrap document scope wrapper (single root scope)
247
247
  if (nodes.length === 1 && nodes[0].type === "scope" && nodes[0].children) {
248
248
  const doc = nodes[0];
249
+ if (doc.scopeType === "comment") return [];
249
250
  if (doc.hasHeading && doc.title) {
250
251
  // Document title scope: render as top-level toggle heading
251
252
  const childBlocks = renderChildren(doc.children, 2, 1);
@@ -290,6 +291,8 @@ function renderNode(node, depth, nestLevel) {
290
291
  }
291
292
 
292
293
  function renderScope(scope, depth, nestLevel) {
294
+ if (scope.scopeType === "comment") return [];
295
+
293
296
  const level = Math.min(3, Math.max(1, depth));
294
297
 
295
298
  if (scope.hasHeading === false) {
package/src/sdoc.js CHANGED
@@ -1,4 +1,9 @@
1
- const SDOC_FORMAT_VERSION = "0.1";
1
+ const SDOC_FORMAT_VERSION = "0.2";
2
+
3
+ const KNOWN_SCOPE_TYPES = [
4
+ "schema", "example", "requirement", "specification", "definition",
5
+ "note", "warning", "test", "task", "api", "config", "deprecated", "comment"
6
+ ];
2
7
 
3
8
  const COMMAND_HEADING = "#";
4
9
  const COMMAND_SCOPE_OPEN = "{";
@@ -45,6 +50,10 @@ function parseTableOptions(text) {
45
50
  for (const flag of flags) {
46
51
  if (flag === "borderless") options.borderless = true;
47
52
  else if (flag === "headerless") options.headerless = true;
53
+ else if (flag === "auto") options.width = "auto";
54
+ else if (/^\d+(?:\.\d+)?%$/.test(flag)) options.width = flag;
55
+ else if (/^\d+px$/.test(flag)) options.width = flag;
56
+ else if (flag === "center" || flag === "left" || flag === "right") options.align = flag;
48
57
  }
49
58
  return options;
50
59
  }
@@ -84,15 +93,7 @@ function parseSdoc(text) {
84
93
  const parsedHeading = parseHeading(cursor.current());
85
94
  cursor.next();
86
95
  const children = parseBlock(cursor, "normal");
87
- const rootNode = {
88
- type: "scope",
89
- title: parsedHeading.title,
90
- id: parsedHeading.id,
91
- children,
92
- hasHeading: true,
93
- lineStart: scopeStartLine,
94
- lineEnd: cursor.index
95
- };
96
+ const rootNode = makeScopeNode(parsedHeading, children, true, scopeStartLine, cursor.index);
96
97
  return { nodes: [rootNode], errors: cursor.errors };
97
98
  }
98
99
 
@@ -228,6 +229,12 @@ function parseBlock(cursor, kind) {
228
229
  continue;
229
230
  }
230
231
 
232
+ // Line comments — skip, don't flush paragraph (invisible to AST)
233
+ if (trimmedLeft.startsWith("//")) {
234
+ cursor.next();
235
+ continue;
236
+ }
237
+
231
238
  if (trimmed === COMMAND_SCOPE_CLOSE) {
232
239
  flushParagraph();
233
240
  cursor.next();
@@ -348,6 +355,21 @@ function extractTrailingOpener(text) {
348
355
  return null;
349
356
  }
350
357
 
358
+ function makeScopeNode(parsedHeading, children, hasHeading, lineStart, lineEnd, extra) {
359
+ const node = {
360
+ type: "scope",
361
+ title: parsedHeading.title,
362
+ id: parsedHeading.id,
363
+ children,
364
+ hasHeading,
365
+ lineStart,
366
+ lineEnd
367
+ };
368
+ if (parsedHeading.scopeType) node.scopeType = parsedHeading.scopeType;
369
+ if (extra) Object.assign(node, extra);
370
+ return node;
371
+ }
372
+
351
373
  function parseScope(cursor) {
352
374
  const scopeStartLine = cursor.index + 1;
353
375
  const headingLine = cursor.current();
@@ -369,15 +391,7 @@ function parseScope(cursor) {
369
391
  } else {
370
392
  children = parseBlock(cursor, "normal");
371
393
  }
372
- return {
373
- type: "scope",
374
- title: parsedHeading.title,
375
- id: parsedHeading.id,
376
- children,
377
- hasHeading: true,
378
- lineStart: scopeStartLine,
379
- lineEnd: cursor.index
380
- };
394
+ return makeScopeNode(parsedHeading, children, true, scopeStartLine, cursor.index);
381
395
  }
382
396
 
383
397
  const parsedHeading = parseHeading(headingLine);
@@ -385,38 +399,14 @@ function parseScope(cursor) {
385
399
 
386
400
  if (blockResult.blockType === "braceless") {
387
401
  const children = parseBracelessBlock(cursor);
388
- return {
389
- type: "scope",
390
- title: parsedHeading.title,
391
- id: parsedHeading.id,
392
- children,
393
- hasHeading: true,
394
- lineStart: scopeStartLine,
395
- lineEnd: cursor.index
396
- };
402
+ return makeScopeNode(parsedHeading, children, true, scopeStartLine, cursor.index);
397
403
  }
398
404
 
399
405
  if (blockResult.blockType === "list") {
400
- return {
401
- type: "scope",
402
- title: parsedHeading.title,
403
- id: parsedHeading.id,
404
- children: [blockResult.children],
405
- hasHeading: true,
406
- lineStart: scopeStartLine,
407
- lineEnd: cursor.index
408
- };
409
- }
410
-
411
- return {
412
- type: "scope",
413
- title: parsedHeading.title,
414
- id: parsedHeading.id,
415
- children: blockResult.children,
416
- hasHeading: true,
417
- lineStart: scopeStartLine,
418
- lineEnd: cursor.index
419
- };
406
+ return makeScopeNode(parsedHeading, [blockResult.children], true, scopeStartLine, cursor.index);
407
+ }
408
+
409
+ return makeScopeNode(parsedHeading, blockResult.children, true, scopeStartLine, cursor.index);
420
410
  }
421
411
 
422
412
  function tryParseInlineBlock(trimmed) {
@@ -494,6 +484,12 @@ function parseBracelessBlock(cursor) {
494
484
  break;
495
485
  }
496
486
 
487
+ // Line comments — skip, don't flush paragraph
488
+ if (trimmedLeft.startsWith("//")) {
489
+ cursor.next();
490
+ continue;
491
+ }
492
+
497
493
  if (trimmed === ",") {
498
494
  flushParagraph();
499
495
  cursor.next();
@@ -710,6 +706,9 @@ function parseListItemLine(cursor, info, allowContinuation = false) {
710
706
  const textForOpener = task ? task.text : raw;
711
707
  const trailing = extractTrailingOpener(textForOpener);
712
708
 
709
+ const listExtra = { shorthand: true };
710
+ if (task) listExtra.task = { checked: task.checked };
711
+
713
712
  if (trailing) {
714
713
  const parsed = parseHeadingText(trailing.text);
715
714
  cursor.next();
@@ -725,17 +724,7 @@ function parseListItemLine(cursor, info, allowContinuation = false) {
725
724
  children = parseBlock(cursor, "normal");
726
725
  }
727
726
 
728
- return {
729
- type: "scope",
730
- title: parsed.title,
731
- id: parsed.id,
732
- children,
733
- hasHeading: true,
734
- shorthand: true,
735
- task: task ? { checked: task.checked } : undefined,
736
- lineStart: itemStartLine,
737
- lineEnd: cursor.index
738
- };
727
+ return makeScopeNode(parsed, children, true, itemStartLine, cursor.index, listExtra);
739
728
  }
740
729
 
741
730
  cursor.next();
@@ -756,44 +745,14 @@ function parseListItemLine(cursor, info, allowContinuation = false) {
756
745
 
757
746
  const block = parseOptionalBlock(cursor);
758
747
  if (!block) {
759
- return {
760
- type: "scope",
761
- title: parsed.title,
762
- id: parsed.id,
763
- children: [],
764
- hasHeading: true,
765
- shorthand: true,
766
- task: task ? { checked: task.checked } : undefined,
767
- lineStart: itemStartLine,
768
- lineEnd: cursor.index
769
- };
748
+ return makeScopeNode(parsed, [], true, itemStartLine, cursor.index, listExtra);
770
749
  }
771
750
 
772
751
  if (block.blockType === "list") {
773
- return {
774
- type: "scope",
775
- title: parsed.title,
776
- id: parsed.id,
777
- children: [block.children],
778
- hasHeading: true,
779
- shorthand: true,
780
- task: task ? { checked: task.checked } : undefined,
781
- lineStart: itemStartLine,
782
- lineEnd: cursor.index
783
- };
784
- }
785
-
786
- return {
787
- type: "scope",
788
- title: parsed.title,
789
- id: parsed.id,
790
- children: block.children,
791
- hasHeading: true,
792
- shorthand: true,
793
- task: task ? { checked: task.checked } : undefined,
794
- lineStart: itemStartLine,
795
- lineEnd: cursor.index
796
- };
752
+ return makeScopeNode(parsed, [block.children], true, itemStartLine, cursor.index, listExtra);
753
+ }
754
+
755
+ return makeScopeNode(parsed, block.children, true, itemStartLine, cursor.index, listExtra);
797
756
  }
798
757
 
799
758
  function parseImplicitListBlock(cursor, listType) {
@@ -843,16 +802,18 @@ function parseTableBody(cursor, tableStartLine, options) {
843
802
  cursor.next();
844
803
  }
845
804
 
805
+ const hasOptions = options.borderless || options.headerless || options.width || options.align;
806
+
846
807
  if (options.headerless) {
847
808
  const tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
848
- if (options.borderless || options.headerless) tableNode.options = options;
809
+ if (hasOptions) tableNode.options = options;
849
810
  return tableNode;
850
811
  }
851
812
 
852
813
  const headers = rows.length > 0 ? rows[0] : [];
853
814
  const body = rows.slice(1);
854
815
  const tableNode = { type: "table", headers, rows: body, lineStart: tableStartLine, lineEnd: cursor.index };
855
- if (options.borderless) tableNode.options = options;
816
+ if (hasOptions) tableNode.options = options;
856
817
  return tableNode;
857
818
  }
858
819
 
@@ -911,7 +872,7 @@ function parseTaskPrefix(raw) {
911
872
  function parseFenceMetadata(meta) {
912
873
  if (!meta) return {};
913
874
  const tokens = meta.split(/\s+/).filter(Boolean);
914
- let lang, src, lines;
875
+ let lang, src, lines, data = false;
915
876
  for (const token of tokens) {
916
877
  if (token.startsWith("src:")) {
917
878
  src = token.slice(4);
@@ -921,11 +882,13 @@ function parseFenceMetadata(meta) {
921
882
  if (match) {
922
883
  lines = { start: parseInt(match[1], 10), end: parseInt(match[2], 10) };
923
884
  }
885
+ } else if (token === ":data") {
886
+ data = true;
924
887
  } else if (!lang) {
925
888
  lang = token;
926
889
  }
927
890
  }
928
- return { lang, src, lines };
891
+ return { lang, src, lines, data };
929
892
  }
930
893
 
931
894
  function parseCodeBlock(cursor) {
@@ -943,26 +906,37 @@ function parseCodeBlock(cursor) {
943
906
 
944
907
  const contentLines = [];
945
908
 
909
+ function buildCodeNode() {
910
+ const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
911
+ if (fenceMeta.src) node.src = fenceMeta.src;
912
+ if (fenceMeta.lines) node.lines = fenceMeta.lines;
913
+ if (fenceMeta.data) {
914
+ node.dataFlag = true;
915
+ if (lang === "json" && !node.src) {
916
+ try {
917
+ node.data = JSON.parse(node.text);
918
+ } catch {
919
+ cursor.error("Invalid JSON in :data code block.");
920
+ }
921
+ }
922
+ }
923
+ return node;
924
+ }
925
+
946
926
  while (!cursor.eof()) {
947
927
  const nextLine = cursor.current();
948
928
  const nextTrimmed = nextLine.replace(/^\s+/, "");
949
929
  const closeMatch = nextTrimmed.match(/^(`{3,})\s*$/);
950
930
  if (closeMatch && closeMatch[1].length >= fenceLen) {
951
931
  cursor.next();
952
- const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
953
- if (fenceMeta.src) node.src = fenceMeta.src;
954
- if (fenceMeta.lines) node.lines = fenceMeta.lines;
955
- return node;
932
+ return buildCodeNode();
956
933
  }
957
934
  contentLines.push(nextLine);
958
935
  cursor.next();
959
936
  }
960
937
 
961
938
  cursor.error("Unterminated code fence.");
962
- const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
963
- if (fenceMeta.src) node.src = fenceMeta.src;
964
- if (fenceMeta.lines) node.lines = fenceMeta.lines;
965
- return node;
939
+ return buildCodeNode();
966
940
  }
967
941
 
968
942
  /**
@@ -1016,12 +990,15 @@ function parseBlockquote(cursor) {
1016
990
  function parseHeading(line) {
1017
991
  const trimmedLeft = line.replace(/^\s+/, "");
1018
992
  const raw = stripHeadingToken(trimmedLeft);
1019
- return parseHeadingText(raw);
993
+ const result = parseHeadingText(raw);
994
+ return result;
1020
995
  }
1021
996
 
1022
997
  function parseHeadingText(raw) {
1023
998
  const split = splitTrailingId(raw);
1024
- return { title: split.text.trimEnd(), id: split.id ? split.id.slice(1) : undefined };
999
+ const result = { title: split.text.trimEnd(), id: split.id ? split.id.slice(1) : undefined };
1000
+ if (split.scopeType) result.scopeType = split.scopeType;
1001
+ return result;
1025
1002
  }
1026
1003
 
1027
1004
  function stripHeadingToken(line) {
@@ -1033,27 +1010,49 @@ function stripHeadingToken(line) {
1033
1010
  }
1034
1011
 
1035
1012
  function splitTrailingId(raw) {
1036
- let i = raw.length - 1;
1037
- while (i >= 0 && /\s/.test(raw[i])) {
1038
- i -= 1;
1039
- }
1013
+ let id = undefined;
1014
+ let scopeType = undefined;
1015
+ let remaining = raw;
1040
1016
 
1041
- const end = i;
1042
- while (i >= 0 && isIdentChar(raw[i])) {
1043
- i -= 1;
1044
- }
1017
+ // Make up to two passes to extract trailing @id and :type in any order
1018
+ for (let pass = 0; pass < 2; pass++) {
1019
+ let i = remaining.length - 1;
1020
+ while (i >= 0 && /\s/.test(remaining[i])) {
1021
+ i -= 1;
1022
+ }
1023
+ if (i < 0) break;
1045
1024
 
1046
- if (i >= 0 && raw[i] === "@" && end > i && isIdentStart(raw[i + 1])) {
1047
- if (!isEscaped(raw, i)) {
1048
- if (i === 0 || /\s/.test(raw[i - 1])) {
1049
- const id = raw.slice(i, end + 1);
1050
- const text = raw.slice(0, i).trimEnd();
1051
- return { text, id };
1025
+ const end = i;
1026
+ while (i >= 0 && isIdentChar(remaining[i])) {
1027
+ i -= 1;
1028
+ }
1029
+ if (i < 0 || end === i) break;
1030
+
1031
+ if (remaining[i] === "@" && !id && isIdentStart(remaining[i + 1])) {
1032
+ if (!isEscaped(remaining, i)) {
1033
+ if (i === 0 || /\s/.test(remaining[i - 1])) {
1034
+ id = remaining.slice(i, end + 1);
1035
+ remaining = remaining.slice(0, i).trimEnd();
1036
+ continue;
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ if (remaining[i] === ":" && !scopeType && isIdentStart(remaining[i + 1])) {
1042
+ if (i === 0 || /\s/.test(remaining[i - 1])) {
1043
+ scopeType = remaining.slice(i + 1, end + 1);
1044
+ remaining = remaining.slice(0, i).trimEnd();
1045
+ continue;
1052
1046
  }
1053
1047
  }
1048
+
1049
+ break;
1054
1050
  }
1055
1051
 
1056
- return { text: raw };
1052
+ const result = { text: remaining };
1053
+ if (id) result.id = id;
1054
+ if (scopeType) result.scopeType = scopeType;
1055
+ return result;
1057
1056
  }
1058
1057
 
1059
1058
  function isHeadingLine(line) {
@@ -1429,13 +1428,18 @@ function renderInlineNodes(nodes) {
1429
1428
  }
1430
1429
 
1431
1430
  function renderScope(scope, depth, isTitleScope = false) {
1431
+ // :comment scopes are not rendered
1432
+ if (scope.scopeType === "comment") return "";
1433
+
1432
1434
  const level = Math.min(6, Math.max(1, depth));
1433
1435
  const children = scope.children.map((child) => renderNode(child, depth + 1)).join("\n");
1434
1436
  const rootClass = isTitleScope ? " sdoc-root" : "";
1435
1437
  const dl = dataLineAttrs(scope);
1438
+ const typeAttr = scope.scopeType ? ` data-scope-type="${escapeAttr(scope.scopeType)}"` : "";
1439
+ const typeClass = scope.scopeType ? ` sdoc-scope-type-${scope.scopeType}` : "";
1436
1440
 
1437
1441
  if (scope.hasHeading === false) {
1438
- return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}"${dl}>${children}</section>`;
1442
+ return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}${typeClass}"${typeAttr}${dl}>${children}</section>`;
1439
1443
  }
1440
1444
 
1441
1445
  const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
@@ -1443,7 +1447,7 @@ function renderScope(scope, depth, isTitleScope = false) {
1443
1447
  const toggle = hasChildren ? `<span class="sdoc-toggle"></span>` : "";
1444
1448
  const heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${renderInline(scope.title)}</h${level}>`;
1445
1449
  const childrenHtml = children ? `\n<div class="sdoc-scope-children">${children}</div>` : "";
1446
- return `<section class="sdoc-scope${rootClass}">${heading}${childrenHtml}</section>`;
1450
+ return `<section class="sdoc-scope${rootClass}${typeClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1447
1451
  }
1448
1452
 
1449
1453
  function renderList(list, depth) {
@@ -1536,7 +1540,16 @@ function renderTable(table) {
1536
1540
  .join("\n");
1537
1541
  const tbody = bodyRows ? `<tbody class="sdoc-table-body">${bodyRows}</tbody>` : "";
1538
1542
 
1539
- return `<table class="${classAttr}"${dl}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
1543
+ const styleParts = [];
1544
+ if (opts.width) {
1545
+ styleParts.push(`width:${opts.width}`);
1546
+ if (opts.width !== "auto") styleParts.push("table-layout:fixed");
1547
+ }
1548
+ if (opts.align === "center") styleParts.push("margin-left:auto", "margin-right:auto");
1549
+ else if (opts.align === "right") styleParts.push("margin-left:auto", "margin-right:0");
1550
+ const styleAttr = styleParts.length ? ` style="${styleParts.join(";")}"` : "";
1551
+
1552
+ return `<table class="${classAttr}"${dl}${styleAttr}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
1540
1553
  }
1541
1554
 
1542
1555
  function renderNode(node, depth) {
@@ -1568,7 +1581,8 @@ function renderNode(node, depth) {
1568
1581
  return `<div class="sdoc-math sdoc-math-block"${dl}>${renderKatex(node.text, true)}</div>`;
1569
1582
  }
1570
1583
  const langClass = node.lang ? ` class="language-${escapeAttr(node.lang)}"` : "";
1571
- return `<div class="sdoc-code-wrap"${dl}><pre class="sdoc-code"><code${langClass}>${escapeHtml(node.text)}</code></pre><button class="sdoc-copy-btn" title="Copy code">\u29C9</button></div>`;
1584
+ const dataLabel = node.dataFlag ? `<span class="sdoc-data-label">data</span>` : "";
1585
+ return `<div class="sdoc-code-wrap"${dl}>${dataLabel}<pre class="sdoc-code"><code${langClass}>${escapeHtml(node.text)}</code></pre><button class="sdoc-copy-btn" title="Copy code">\u29C9</button></div>`;
1572
1586
  }
1573
1587
  default:
1574
1588
  return "";
@@ -2009,6 +2023,22 @@ const DEFAULT_STYLE = `
2009
2023
  padding-left: 1.2rem;
2010
2024
  }
2011
2025
 
2026
+ .sdoc-data-label {
2027
+ position: absolute;
2028
+ top: 6px;
2029
+ left: 8px;
2030
+ z-index: 1;
2031
+ font-size: 0.65rem;
2032
+ font-weight: 600;
2033
+ text-transform: uppercase;
2034
+ letter-spacing: 0.05em;
2035
+ padding: 1px 5px;
2036
+ border-radius: 3px;
2037
+ background: rgba(59, 130, 195, 0.15);
2038
+ color: #3b82c3;
2039
+ pointer-events: none;
2040
+ }
2041
+
2012
2042
  `;
2013
2043
 
2014
2044
  const PRINT_STYLE = `
@@ -2314,17 +2344,35 @@ function listSections(nodes) {
2314
2344
  id: node.id || null,
2315
2345
  derivedId: slugify(node.title),
2316
2346
  title: node.title,
2347
+ scopeType: node.scopeType || null,
2317
2348
  preview: firstParagraphPreview(node.children || [], 100)
2318
2349
  }));
2319
2350
  }
2320
2351
 
2352
+ function collectDataBlocks(children) {
2353
+ const data = [];
2354
+ for (const child of children) {
2355
+ if (child.type === "code" && child.dataFlag && child.data !== undefined) {
2356
+ data.push(child.data);
2357
+ }
2358
+ }
2359
+ return data;
2360
+ }
2361
+
2321
2362
  function extractSection(nodes, sectionId) {
2322
2363
  const scopes = getContentScopes(nodes);
2323
2364
 
2365
+ function buildResult(node) {
2366
+ const data = collectDataBlocks(node.children || []);
2367
+ const result = { title: node.title, content: collectPlainText(node.children || []) };
2368
+ if (data.length) result.data = data;
2369
+ return result;
2370
+ }
2371
+
2324
2372
  // First pass: match explicit @id (case-sensitive)
2325
2373
  for (const node of scopes) {
2326
2374
  if (node.id && node.id === sectionId) {
2327
- return { title: node.title, content: collectPlainText(node.children || []) };
2375
+ return buildResult(node);
2328
2376
  }
2329
2377
  }
2330
2378
 
@@ -2332,13 +2380,41 @@ function extractSection(nodes, sectionId) {
2332
2380
  const lowerTarget = sectionId.toLowerCase();
2333
2381
  for (const node of scopes) {
2334
2382
  if (slugify(node.title).toLowerCase() === lowerTarget) {
2335
- return { title: node.title, content: collectPlainText(node.children || []) };
2383
+ return buildResult(node);
2336
2384
  }
2337
2385
  }
2338
2386
 
2339
2387
  return null;
2340
2388
  }
2341
2389
 
2390
+ function extractDataBlocks(nodes) {
2391
+ const results = [];
2392
+ function walk(nodeList, scopeId, scopeType, scopeTitle) {
2393
+ for (const node of nodeList) {
2394
+ if (node.type === "code" && node.dataFlag && node.data !== undefined) {
2395
+ results.push({
2396
+ scopeId: scopeId || null,
2397
+ scopeType: scopeType || null,
2398
+ scopeTitle: scopeTitle || null,
2399
+ data: node.data
2400
+ });
2401
+ }
2402
+ if (node.type === "scope") {
2403
+ walk(node.children || [], node.id, node.scopeType, node.title);
2404
+ }
2405
+ if (node.type === "list" && node.items) {
2406
+ for (const item of node.items) {
2407
+ if (item.type === "scope") {
2408
+ walk(item.children || [], item.id, item.scopeType, item.title);
2409
+ }
2410
+ }
2411
+ }
2412
+ }
2413
+ }
2414
+ walk(nodes, null, null, null);
2415
+ return results;
2416
+ }
2417
+
2342
2418
  function extractAbout(nodes) {
2343
2419
  const doc = getDocumentScope(nodes);
2344
2420
  const children = doc ? doc.children : nodes;
@@ -2364,6 +2440,12 @@ async function resolveIncludes(nodes, resolverFn) {
2364
2440
  text = allLines.slice(node.lines.start - 1, node.lines.end).join("\n");
2365
2441
  }
2366
2442
  node.text = text;
2443
+ // Re-parse JSON for :data blocks after include resolution
2444
+ if (node.dataFlag && node.lang === "json") {
2445
+ try {
2446
+ node.data = JSON.parse(node.text);
2447
+ } catch { /* leave node.data undefined — caller can check */ }
2448
+ }
2367
2449
  } catch (err) {
2368
2450
  node.text = `// Error: Could not read ${node.src} — ${err.message}`;
2369
2451
  }
@@ -2392,6 +2474,8 @@ module.exports = {
2392
2474
  listSections,
2393
2475
  extractSection,
2394
2476
  extractAbout,
2477
+ extractDataBlocks,
2478
+ KNOWN_SCOPE_TYPES,
2395
2479
  // Low-level helpers for custom renderers (e.g. slide-renderer)
2396
2480
  parseInline,
2397
2481
  renderKatex,
@@ -130,15 +130,26 @@ function renderTable(table) {
130
130
  .join("\n");
131
131
  const tbody = bodyRows ? `<tbody>\n${bodyRows}\n</tbody>` : "";
132
132
 
133
- return `<table${classAttr}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
133
+ const styleParts = [];
134
+ if (opts.width) {
135
+ styleParts.push(`width:${opts.width}`);
136
+ if (opts.width !== "auto") styleParts.push("table-layout:fixed");
137
+ }
138
+ if (opts.align === "center") styleParts.push("margin-left:auto", "margin-right:auto");
139
+ else if (opts.align === "right") styleParts.push("margin-left:auto", "margin-right:0");
140
+ const styleAttr = styleParts.length ? ` style="${styleParts.join(";")}"` : "";
141
+
142
+ return `<table${classAttr}${styleAttr}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
134
143
  }
135
144
 
136
145
  function renderNestedScope(scope) {
146
+ if (scope.scopeType === "comment") return "";
137
147
  const heading = scope.hasHeading !== false && scope.title
138
148
  ? `<h3>${renderInline(scope.title)}</h3>`
139
149
  : "";
150
+ const typeAttr = scope.scopeType ? ` data-scope-type="${escapeAttr(scope.scopeType)}"` : "";
140
151
  const children = scope.children.map((child) => renderNode(child)).join("\n");
141
- return `<section>${heading}\n${children}</section>`;
152
+ return `<section${typeAttr}>${heading}\n${children}</section>`;
142
153
  }
143
154
 
144
155
  function renderChildren(nodes) {
@@ -179,6 +190,10 @@ function extractNotes(children) {
179
190
  const notes = [];
180
191
  const rest = [];
181
192
  for (const child of children) {
193
+ if (child.type === "scope" && child.scopeType === "comment") {
194
+ // :comment scopes are excluded from both notes and content
195
+ continue;
196
+ }
182
197
  if (child.type === "scope" && child.id && child.id.toLowerCase() === "notes") {
183
198
  notes.push(child);
184
199
  } else {
@@ -208,8 +223,8 @@ function renderSlide(scope, slideIndex, overlayHtml) {
208
223
  let bodyHtml;
209
224
  if (config.layout === "two-column") {
210
225
  // In two-column layout, child scopes become columns
211
- const columns = contentNodes.filter((n) => n.type === "scope");
212
- const nonColumns = contentNodes.filter((n) => n.type !== "scope");
226
+ const columns = contentNodes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
227
+ const nonColumns = contentNodes.filter((n) => n.type !== "scope" || n.scopeType === "comment");
213
228
  const preamble = nonColumns.length ? renderChildren(nonColumns) : "";
214
229
  const columnsHtml = columns
215
230
  .map((col) => {
@@ -256,8 +271,8 @@ function renderSlides(nodes, options = {}) {
256
271
  slideScopes = nodes;
257
272
  }
258
273
 
259
- // Filter to scope nodes only (skip stray paragraphs at top level)
260
- const slides = slideScopes.filter((n) => n.type === "scope");
274
+ // Filter to scope nodes only (skip stray paragraphs and :comment scopes)
275
+ const slides = slideScopes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
261
276
 
262
277
  // Build per-slide footer: < CONFIDENTIAL ---gap--- Company >
263
278
  const footerParts = [];