v0.34.0
Released September 15, 2026 · All releases
Content validation. Every rune schema declares required attributes, typed attributes and matches enums. Markdoc can check all of them, and has been able to the entire time. Nothing in the build ever asked it to. This milestone connects the validator that exists to the content it was written for.
Changes
- Validate content during the build (WORK-556, SPEC-132 phase 1)
- Every rune schema declares required attributes, typed attributes and
matchesenums. Markdoc can check all of them and has been able to the whole time. Nothing in the build ever asked it to — the validator ran in the language server and over the fixture corpus, never over a user's pages (BUG-014). Markdoc.validate()now runs per page inpackages/content/src/site.ts, against the same config object handed toMarkdoc.transform. Plugin runes are covered with no extra wiring, because they are already merged into that config.- Findings become
PipelineWarnings and nothing else. Nothing is injected into the page: a validation finding annotates content that rendered perfectly well, so it belongs beside the page rather than in it.PipelineWarning.phasegains a'validate'member; core findings carrypluginName: 'core', and a finding on a plugin-contributed page is attributed to the contributing plugin and says the page was generated. - Two error ids are enabled:
tag-undefinedandattribute-undefined. The allow-list is explicit, so adding an id is a deliberate act.variable-undefinedis not enabled and a test pins that it stays out — Markdoc validates the full path and has no scope model, so a collection template using$item.data.*would light up entirely. - Fenced examples stay exempt.
escapeFenceTagsalready neutralises tags inside fences before parsing; a test now pins that ordering, with a companion assertion that the same rune outside a fence is reported. - Three real defects this found immediately, all of the "silently wrong" class the feature exists to catch:
{% code %}cells in the generated rune-attribute tables came out block-level.bindRowindata-pipeline.tscloned nodes without carryinginlineacross — under a comment that claimed it copied exactly that. 6,609 findings; fixed here, along with the same omission incloneNode.{% ref "Sandbox" %}on the security page was written as an opening tag rather than self-closing, so the ref swallowed the rest of the paragraph.{% hint type="tip" %}on an older blog post —tipis not one ofcaution | check | note | warning, so it rendereddata-type="tip"with no CSS behind it.- Plus one in the test suite:
plan-site-dogfood-real.test.tshad been building the plan site with every plan rune missing from Markdoc's tag set, and passing, because an undefined tag drops and renders its children as prose. - A clean build of
site/andplan-site/now reports zero findings from these two ids. - Enforce attribute schemas during the build (WORK-558, SPEC-132 phase 2)
attribute-value-invalid,attribute-missing-requiredandattribute-type-invalidjoin the validation allow-list. This is the half that makes rune schemas mean something:requiredandmatchesare declared on ~175 tags and, until now, enforced on none of them.- It is also the first release in which refrakt's custom attribute validators run at all.
transform()does not invoke aCustomAttributeTypeInterface'svalidate()— onlyMarkdoc.validate()does — soSeparatedString,SpaceSeparatedListandSpaceSeparatedNumberListhad been dead code in the build path since they were written. A test now proves one rejects non-numeric input through the real pipeline. - Severity correction. All four custom validators returned
level: 'critical'forattribute-type-invalid, an id Markdoc emits aterror— the identical failure reported at two severities depending on which code path produced it. They now emiterror. This matters becausecriticalis the band configuration cannot silence: it means "the document could not be understood", which an out-of-range attribute is not. - Three schema defects this found, each one a case where the schema disagreed with something else in the repo and only a validator could notice:
frame-displacerejected"both"— a value Lumina has styled all along ([data-displace="both"]). The page rendered correctly and the schema refused it; nothing else in the toolchain compares the two.collection.limitwas declaredStringwhile its own documentation writeslimit=5andlimit=20, and the resolver reads it asNumber(limitRaw). NowNumber, matching the plan plugin'sbacklogandplan-activity.aggregate.limithad the identical declaration. No content exercised it, so it produced no finding; corrected anyway.- Markdoc's
if/elseare overridden to drop theObjecttype on theirprimaryattribute. That type describes expression syntax —{% if $var %}passes an AST node — and stops holding once preprocess hooks substitute variables for their values, so{% if true %}failed it. That accounted for 3,302 findings onsite/, every one a false positive, and the check cannot catch a real error in either direction.render: falseis preserved. - Five content fixes in this repo's own docs, all quoted booleans and numbers (
showContrast="true",route="true",limit="5") that worked by accident because a non-empty string is truthy — and would have failed silently the first time anyone wrote the negative case. SeparatedStringand friends are now exported from@refrakt-md/runes. They were not before, which is whyplugins/mediacarries its own copies.- A clean build of
site/andplan-site/reports zero findings across all five enabled ids, down from 9,926 when the pass was first switched on. - A narrow escape hatch for content validation (WORK-559, SPEC-132 D5)
enabled: falsestops reporting every suppressible finding for that site.idsreplaces the default error-id list entirely.disableIdsstops treating specific ids as problems, leaving the rest reporting. This is the one to reach for first.- A disabled id is demoted to
info, not dropped. Someone who has judged an id unimportant should stop seeing it in the build summary, but it should not become invisible — a reader who goes looking, or the editor's validation rail, can still see what was set aside, so turning the id back on later is an informed choice rather than a leap. An id that was never in the allow-list is a different thing and is simply not reported: there is no value in aninfostream of every check the project has decided against. criticalfindings are reported whatever you configure. A parse error, an unclosed tag, a tag in a position its schema forbids — these mean the document could not be understood, which is not a matter of preference. A test pins that a fully disabled config still surfaces one.- There is no per-page or per-tag suppression, and there will not be. That granularity invites silencing the one call site that revealed a real bug, which is how a validation feature becomes decoration.
- The field is in
refrakt.config.schema.jsonwith a description, so it appears in the generated configuration reference, and the reference page now lists which error ids are on out of the box and what each one catches. - Remove the
errorrune (WORK-555) {% error %}is gone. It was a leftover from refrakt's predecessor: a schema that took a MarkdocValidationErrorand emitted a bare<tr>of four<td>s. Nothing in this repo has ever constructed it, in any version.- It was not merely dead weight — it was a live crash.
runeTagMapapplies no internal filter, so the tag was author-writable and appeared inrefrakt inspect --listand in language-server completion as "Error reporting table". It declares no required attributes and its transform dereferences immediately: createContentModelSchemacallstransformwith notry/catch, so an author who founderrorin autocomplete and wrote{% error /%}gotCannot read properties of undefined (reading 'id')and a dead build.- It also sat outside every convention the project has since built: no engine config entry, no BEM block, no CSS in Lumina, no structure contract, no doc page, no fixture. It was invisible to every convention and visible in every autocomplete.
- Breaking in name only. Removing a catalogued rune is nominally breaking, but no user can have had a working
{% error %}— writing it crashed the build. The affected set is provably empty. nodes.erroris untouched. That is Markdoc's built-in parse-error node and is unrelated to this rune.- The exception lists shrink with it:
'error'is dropped fromEXCLUDED_RUNESinreference.tsand fromPAGELESSincheck-rune-docs.mjs, rather than being left behind as a suppression for something that no longer exists.