markdown-schema 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.
@@ -0,0 +1,419 @@
1
+ # Markdown Template Authoring Guide
2
+
3
+ A practical guide for authoring and validating markdown templates that
4
+ pair with `markdown-schema` schemas.
5
+
6
+ ## How Templates Work
7
+
8
+ Templates use `<!-- TEMPLATE-ONLY: ... -->` HTML comments to carry both
9
+ machine-readable schema directives and author-facing prose. These comments
10
+ are invisible in rendered markdown until replaced with real content.
11
+
12
+ **Key principle:** Fixed structural scaffolding (headings, section titles, stable
13
+ labels like `Type:`, table headers) stays as plain markdown outside the comment
14
+ tags. Everything that changes per instance — placeholders, sample values,
15
+ schema constraints, and authoring guidance — goes inside `<!-- TEMPLATE-ONLY: -->`
16
+ blocks. Constants outside, variables inside.
17
+
18
+ **The "survives filling" test.** For every span of text in the template, ask:
19
+ _will this exact text still be present, character-for-character, in every
20
+ correctly filled instance?_ If yes, it's a constant — leave it outside. If no,
21
+ it's a variable — wrap it in `<!-- TEMPLATE-ONLY: -->`. Apply the test at the
22
+ finest granularity that still reads naturally: usually a label, a column
23
+ header, or a heading number, but never a whole line if only part of it varies.
24
+
25
+ **Common trap — enum placeholders.** A list of allowed values like
26
+ `Root | Layout | Container | View` _looks_ like fixed scaffolding because it
27
+ contains no `[brackets]`, but the author is meant to pick exactly one and
28
+ delete the rest. It fails the survives-filling test and therefore belongs
29
+ inside `<!-- TEMPLATE-ONLY: -->`. The same applies to any choice list, sample
30
+ value, or example identifier, even when no brackets are used.
31
+
32
+ **Anti-example:**
33
+
34
+ ```markdown
35
+ <!-- WRONG — the enum is a placeholder, not a constant -->
36
+
37
+ - Type: Root | Layout | Container | View | Provider | Primitive
38
+
39
+ <!-- RIGHT — label outside, value inside -->
40
+
41
+ - Type: <!-- TEMPLATE-ONLY: enum: Root | Layout | Container | View | Provider | Primitive; required -->
42
+ ```
43
+
44
+ ## Schema Pairing Rule
45
+
46
+ The **template is the source of truth**. `validate-md` derives the schema from
47
+ the template's `<!-- TEMPLATE-ONLY: -->` directives at runtime — no separate
48
+ TypeScript schema file is needed. An optional `*.refine.ts` sibling adds
49
+ cross-section invariants that can't be expressed as directives:
50
+
51
+ ```
52
+ component.template.md ← source of truth; directives encode the schema
53
+ component.refine.ts ← (optional) cross-section Zod refinements
54
+ ```
55
+
56
+ To validate a filled document:
57
+
58
+ ```sh
59
+ validate-md --template component.template.md example.md
60
+ ```
61
+
62
+ ## Two Kinds of Directives
63
+
64
+ Directives come in two structural shapes — **inline** and **block** — and the
65
+ distinction is not cosmetic; it's enforced by the parser.
66
+
67
+ ### Inline directives
68
+
69
+ Sit _inside_ a heading, list item, or paragraph. They **must close on the same
70
+ line as the opener** (single-line). Multi-line bodies are a hard error.
71
+
72
+ ```markdown
73
+ - Stage: <!-- TEMPLATE-ONLY: enum: Alpha | Beta | GA; required -->
74
+ - SKU: <!-- TEMPLATE-ONLY: string; regex `^[A-Z]{3}-\d{4}$`; required -->
75
+ ```
76
+
77
+ The two inline kinds are `string` (with optional modifiers including `regex`)
78
+ and `enum:`. See _Directive Reference_ below.
79
+
80
+ ### Block directives
81
+
82
+ Sit on their own line, opener at column ≤ 3 (CommonMark block-HTML rule),
83
+ closer (`-->`) on its own line. Bodies span multiple lines. **Body lines must
84
+ start at column 0** (no leading whitespace).
85
+
86
+ ```markdown
87
+ <!-- TEMPLATE-ONLY: row; min-rows: 1
88
+ Prop Name: string; regex `^[a-z][a-zA-Z0-9]*$`; required
89
+ Type: string; required
90
+ Required: enum: Yes | No; required
91
+ -->
92
+ ```
93
+
94
+ Modifiers on the **opener** line are semicolon-separated (same shape as inline
95
+ directives). The body holds whatever the directive kind allows: column specs
96
+ for `row`, `//` prose lines for `guide`, nothing for `section` or `freetext`.
97
+
98
+ The block kinds are `freetext`, `row`, `section`, and `guide`.
99
+
100
+ ## Directive Reference
101
+
102
+ ### `string` — inline scalar field
103
+
104
+ ```markdown
105
+ - Parent Component: <!-- TEMPLATE-ONLY: string; required -->
106
+ - Source: <!-- TEMPLATE-ONLY: string; optional; only-if Type=Primitive -->
107
+ - SKU: <!-- TEMPLATE-ONLY: string; regex `^[A-Z]{3}-\d{4}$`; required -->
108
+ - Currency: <!-- TEMPLATE-ONLY: string; optional; default=USD -->
109
+ ```
110
+
111
+ Modifiers (semicolon-separated):
112
+
113
+ - `required` / `optional` — presence constraint (default: required)
114
+ - `regex` — pattern validated by `new RegExp(pattern)`, written between backticks in the directive. Note: `regex` is a **modifier** of `string`, not a type of its own. A bare `regex` without a leading `string;` is a hard error.
115
+ - `default=<value>` — fallback when the value is blank
116
+ - `only-if <Key>=<Value>` — field is permitted only when the named sibling
117
+ field equals the given value (enforced as a `superRefine` on the section)
118
+
119
+ ### `enum:` — inline choice list
120
+
121
+ ```markdown
122
+ - Type: <!-- TEMPLATE-ONLY: enum: Root | Layout | Container | View | Provider | Primitive; required -->
123
+ - Tier: <!-- TEMPLATE-ONLY: enum: Free | Pro | Enterprise; optional -->
124
+ ```
125
+
126
+ Choices are split on `|` and trimmed. The field schema becomes `z.enum([...])`.
127
+
128
+ **Escapes** — for choices containing `|` or `\`:
129
+
130
+ - `\|` → literal `|`
131
+ - `\\` → literal `\`
132
+ - Other `\x` → emitted as literal `\x` (lenient).
133
+
134
+ ```markdown
135
+ - Logical Op: <!-- TEMPLATE-ONLY: enum: AND | OR | A \| B (either) | C \\ D; required -->
136
+ ```
137
+
138
+ The third choice is the literal string `A | B (either)`; the fourth is
139
+ `C \ D`. Modifiers (`required`, `optional`) work the same as `string`.
140
+
141
+ ### H1 placeholder
142
+
143
+ The H1 carries an inline directive that validates the document title:
144
+
145
+ ```markdown
146
+ # Release Notes: <!-- TEMPLATE-ONLY: string; required -->
147
+
148
+ # Component Spec: <!-- TEMPLATE-ONLY: string; regex `^[A-Z][A-Za-z0-9]*$`; required -->
149
+ ```
150
+
151
+ The title in the filled document is matched as `<prefix> <value>`, where
152
+ `prefix` is the literal H1 text up to the directive and `value` is validated
153
+ by the directive.
154
+
155
+ ### H3 directive (in repeated sections)
156
+
157
+ Sections that contain repeated H3 sub-groups can validate each H3's heading
158
+ text. Place an inline directive inside the H3:
159
+
160
+ ```markdown
161
+ ## Changes
162
+
163
+ ### <!-- TEMPLATE-ONLY: string; regex `^(add|fix|chore): .+$`; required -->
164
+
165
+ Description of the change.
166
+ ```
167
+
168
+ After filling, an H3 reads e.g. `### add: loadPlugin()`. Only `string` and
169
+ `enum:` directives are allowed in H3s.
170
+
171
+ **H2 directives are not supported.** The H2 heading text is the JSON section
172
+ key (derived via `headingToKey`) — keys must be fixed.
173
+
174
+ ### `row` — table row schema (block directive)
175
+
176
+ Placed between the header-separator row and the sample data row:
177
+
178
+ ```markdown
179
+ | Prop Name | Type | Default | Required |
180
+ | --------- | ---- | ------- | -------- |
181
+
182
+ <!-- TEMPLATE-ONLY: row; min-rows: 1; max-rows: 50
183
+ Prop Name: string; regex `^[a-z][a-zA-Z0-9]*$`; required
184
+ Type: string; required
185
+ Default: string; default=—
186
+ Required: enum: Yes | No; required
187
+ -->
188
+
189
+ | propName | string | — | Yes |
190
+ ```
191
+
192
+ - **Opener modifiers**: `min-rows: N`, `max-rows: N`. Semicolon-separated.
193
+ - **Body**: one column spec per line. Each spec is `Header: <field-modifiers>`,
194
+ where field modifiers are the same grammar as inline `string` / `enum:`
195
+ directives. Column header text matches the GFM table header literally
196
+ (preserves spaces and casing).
197
+
198
+ ### `section` — section-level constraints (block directive)
199
+
200
+ Placed immediately under the section's H2. In most cases it fits on one line:
201
+
202
+ ```markdown
203
+ ## 7. Internal State
204
+
205
+ <!-- TEMPLATE-ONLY: section; optional; remove-if Type=View -->
206
+ ```
207
+
208
+ Modifiers (opener-line, semicolon-separated):
209
+
210
+ - `optional` — the H2 may be absent in the filled doc; the section's JSON
211
+ value is `undefined`.
212
+ - `remove-if <Key>=<Value>` — the section must be absent when the named
213
+ sibling field equals the value. Present when it shouldn't be → hard error.
214
+ - `min-groups: N` / `max-groups: N` — for repeated sections (H3 sub-groups),
215
+ enforces the count of groups.
216
+
217
+ ### `freetext` — free-form markdown section (block directive)
218
+
219
+ Placed immediately under the section's H2 to opt the section into free-form
220
+ mode. The section's JSON value is the body serialized back to a markdown
221
+ string (all node types preserved: paragraphs, fenced code, lists, tables,
222
+ blockquotes, thematic breaks, H3+ headings).
223
+
224
+ ```markdown
225
+ ## 1. Summary
226
+
227
+ <!-- TEMPLATE-ONLY: freetext; required -->
228
+
229
+ A paragraph, a code block, a list — any markdown content.
230
+ ```
231
+
232
+ Modifiers (opener-line, semicolon-separated):
233
+
234
+ - `required` — the section body must be non-empty (default).
235
+ - `optional` — the H2 may be absent; JSON value is `undefined`.
236
+
237
+ **Authoring rules for free-text sections:**
238
+
239
+ - Use H3+ for in-body structure; H2 always terminates the section.
240
+ - No `<!-- TEMPLATE-ONLY: -->` directives of any kind inside the body —
241
+ including `guide` blocks. Place `guide` blocks _above_ the H2.
242
+ - Sections with no recognized shape and no `freetext` directive are a
243
+ hard parse error.
244
+ - **Serialization normalizations:** the body is round-tripped through
245
+ `mdast-util-to-markdown`. Bare URLs become autolink literals
246
+ (`https://…` → `<https://…>`). Thematic breaks may be normalized to
247
+ `***`. Treat the JSON value as a markdown string, not as the original
248
+ source bytes.
249
+
250
+ ### `guide` — author-facing prose (block directive)
251
+
252
+ Free-form prose, intended for the human filling the template. The parser
253
+ ignores it entirely — it has no schema effect. Body lines must start with
254
+ `//` (after optional leading whitespace) or be blank.
255
+
256
+ ```markdown
257
+ <!-- TEMPLATE-ONLY: guide
258
+ // One concise sentence per bullet. Keep under 80 chars.
259
+ // If you need to call out a non-obvious constraint, do it here.
260
+ -->
261
+
262
+ - Highlights: <!-- TEMPLATE-ONLY: string; required -->
263
+ ```
264
+
265
+ **Authoring convention:** place a `guide` block immediately _before_ the
266
+ field, list, table, sub-heading, or section it documents. A `guide` at the
267
+ top of a section documents the whole section; one at the top of the file
268
+ documents the whole template. The parser doesn't enforce placement, but
269
+ authors read top-to-bottom and expect explanation before the thing being
270
+ explained.
271
+
272
+ A good `guide` answers at least one of:
273
+
274
+ - **What** is this field, in everyday words? (Not "string; required" but
275
+ "the customer-facing name of the release.")
276
+ - **Why** does this constraint exist? (Not "regex `^v\d+\.\d+\.\d+$`" but
277
+ "must match SemVer because our changelog tooling sorts by it.")
278
+ - **When** should this section/field appear, in the author's terms? (Not
279
+ "only-if Type=Primitive" but "fill this only if you picked 'Primitive'
280
+ in the Type field above.")
281
+ - **Example** of a good answer, when the format is unobvious.
282
+
283
+ Aim each `guide` at the author who will fill the template, not at a fellow
284
+ engineer reading the template's source.
285
+
286
+ **What to avoid:**
287
+
288
+ - **Restating the grammar.** `// string; required` adds no information.
289
+ - **Engineer-speak the author won't share.** "PascalCase identifier
290
+ conforming to `[A-Z][A-Za-z0-9]*$`" → say "starts with a capital
291
+ letter, e.g. `Card`."
292
+ - **Comments that go stale faster than the template.** Reference the
293
+ _intent_ of a constraint, not the current implementation that enforces it.
294
+
295
+ ## Section Extractor Inference
296
+
297
+ The parser infers the extractor from the section body's structure:
298
+
299
+ | Body shape | Extractor | Returns |
300
+ | ------------------------------------------- | ------------ | ------------------------------------------------- |
301
+ | `freetext` directive present | `freetext` | `string` (markdown source, round-tripped) |
302
+ | Sub-headings (H3 inside H2) | `repeated` | `{ heading: string; items: string[] }[]` |
303
+ | GFM table (with `row` directive) | `table` | `Record<string, string>[]` |
304
+ | Labeled bullet list (`- Key: value`) | `bulletList` | `Record<string, string \| undefined>` |
305
+ | GFM task list (`- [ ]` / `- [x]`) | `taskList` | `{ checked: boolean; text: string }[]` |
306
+ | Plain unordered bullet list | `bulletList` | `string[]` |
307
+ | None of the above (no `freetext` directive) | **error** | "no recognized shape; add a `freetext` directive" |
308
+
309
+ The H2 heading text becomes the JSON key (via `headingToKey`, e.g.
310
+ `## 1. Internal State` → `internalState`). The section's value is whatever
311
+ the inferred extractor produces.
312
+
313
+ ## Cross-Section Invariants (`*.refine.ts`)
314
+
315
+ Rules that span more than one section belong in a `*.refine.ts` sibling.
316
+ The CLI auto-discovers it; no directive is required to enable it.
317
+
318
+ ```ts
319
+ // component.refine.ts
320
+ import type { z } from "zod";
321
+
322
+ export const refine = (doc: any, ctx: z.core.$RefinementCtx): void => {
323
+ const classification = doc.classification as Record<
324
+ string,
325
+ string | undefined
326
+ >;
327
+ if (
328
+ classification["Type"] === "View" &&
329
+ Array.isArray(doc.internalState) &&
330
+ doc.internalState.length > 0
331
+ ) {
332
+ ctx.addIssue({
333
+ code: "custom",
334
+ path: ["internalState"],
335
+ message: "View components must not have internal state",
336
+ });
337
+ }
338
+ };
339
+ ```
340
+
341
+ Document each invariant in a `guide` block under the H1 so authors filling
342
+ the template know what cross-section rules apply.
343
+
344
+ ## Authoring Workflow
345
+
346
+ 1. Copy the `*.template.md` file to a new document.
347
+ 2. Read every `<!-- TEMPLATE-ONLY: guide ... -->` block — that's where the
348
+ template author put guidance for you.
349
+ 3. Read every other `<!-- TEMPLATE-ONLY: -->` directive to understand each
350
+ section's structure and constraints.
351
+ 4. Fill in each section:
352
+ - **inline directive** (`string`, `enum:`): write the value, delete the
353
+ `<!-- TEMPLATE-ONLY: ... -->` comment.
354
+ - **`row` directive**: replace the sample row(s) with real data. Delete
355
+ the `row` directive block (it sits between separator and sample row,
356
+ not in the filled doc).
357
+ - **`section` directive**: add content for the section; delete the entire
358
+ section's H2 + body if the directive marks it `optional` or
359
+ `remove-if` says to remove it.
360
+ - **`guide` block**: delete it (or leave it — it's invisible in rendered
361
+ markdown either way, but cleaner without).
362
+ 5. Run `validate-md --template component.template.md example.md` and confirm `OK`.
363
+ 6. Grep for leftover directives: `grep '<!-- TEMPLATE-ONLY' example.md` must
364
+ return empty.
365
+
366
+ ## Filling Checklist
367
+
368
+ - [ ] All `<!-- TEMPLATE-ONLY: -->` comment blocks replaced or removed
369
+ - [ ] No placeholder text remains outside comment blocks
370
+ - [ ] `enum:` choices collapsed to a single value (with escapes preserved if
371
+ the choice contained `|` or `\`)
372
+ - [ ] All required fields and table columns are populated
373
+ - [ ] Optional sections removed if not applicable (or populated if present)
374
+ - [ ] Cross-section invariants (per the H1 `guide` block and `*.refine.ts`)
375
+ are satisfied
376
+ - [ ] `validate-md --template <template> <doc>` prints `OK`
377
+ - [ ] `grep '<!-- TEMPLATE-ONLY' <doc>` returns empty
378
+
379
+ ## Validation Error Interpretation
380
+
381
+ `validate-md` prints three kinds of errors:
382
+
383
+ **Directive errors** (`DirectiveError`): the _template_ itself is malformed
384
+ — unknown keyword, multi-line inline directive, indented body line, etc.
385
+ Fix the template, not the filled doc. Includes file/line/column.
386
+
387
+ ```
388
+ component.template.md:18:3: unknown TEMPLATE-ONLY directive kind: "pik one"
389
+ component.template.md:42:1: inline TEMPLATE-ONLY directive must close on the same line; "-->" not found before line end
390
+ ```
391
+
392
+ **Structural errors** (`Error.message`): the filled document is missing a
393
+ required heading, a required table, or has a malformed structure.
394
+
395
+ ```
396
+ example.md: schema validation failed
397
+ - inputs: must contain a table
398
+ ```
399
+
400
+ **Zod issue list** (`path: message`): the document has the right structure
401
+ but a value violates a directive constraint or refine rule. Fix the value.
402
+
403
+ ```
404
+ example.md: schema validation failed
405
+ - classification.Type: Invalid option: expected one of "Root", "Layout", ...
406
+ - internalState: View components must not have internal state
407
+ ```
408
+
409
+ When an issue path doesn't map to a single section's schema (e.g. path is
410
+ `internalState` but the error mentions another section's field), suspect a
411
+ cross-section refine. Check the `*.refine.ts` source.
412
+
413
+ **Common false alarms:**
414
+
415
+ - Leftover `<!-- TEMPLATE-ONLY: -->` blocks in the filled document — grep for them.
416
+ - Heading text drift (changed heading after filling) — compare to template.
417
+ - Smart quotes from copy-paste (`"` instead of `"`) — replace with straight quotes.
418
+ - A choice that contains `|` written without `\|` escape — read the directive's
419
+ expected choices carefully.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gergely Szerovay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.