mdsmith
Esc
    v0.55.1 GitHub

    Front-matter agreement

    Two schema keys that tie a document’s front matter to the rest of its contract: frontmatter-closed:, which decides whether an undeclared front-matter key is an error, and \#(fmvar(name)) interpolation inside filename: and path-pattern: globs, which makes a path agree with a front-matter value.

    Two schema keys tie a document’s front matter to the rest of its contract. frontmatter-closed: decides whether a key the schema never declared is an error. \#(fmvar(name)) interpolation lets a filename: or path-pattern: glob require the path to agree with a field’s value.

    Both sit alongside the section schema , which covers the heading: grammar the same schema block carries.

    # frontmatter-closed

    Front matter is closed by default. A key absent from frontmatter: reports:

    model: got "opus", expected not declared in schema

    frontmatter-closed: states that default explicitly, or turns it off:

    ValueEffect
    absentclosed (the default)
    trueclosed, stated explicitly
    falseopen — undeclared keys pass

    Declared keys keep their constraints under either setting. false only stops the “not declared” report.

    The key is valid only on a schema that also declares a non-empty frontmatter: map. Without one mdsmith emits no front-matter constraint at all, so the setting would be dead; that pairing parse-errors. This mirrors the closed: / sections: guard in the section schema.

    Set the key on an inline kind schema or on a named schema file. A proto.md cannot carry it: each front-matter key of a proto.md names a document field, so mdsmith reports frontmatter-closed: there as an invalid schema.

    # Example: an APM prompt

    APM’s .apm/prompts/*.prompt.md preserves exactly five front-matter keys. apm compile drops every other one, but only at compile time — after the file is written and shipped. Declaring the closure catches a sixth key while the author is still in the editor:

    kinds:
      apm-prompt:
        path-pattern: ".apm/prompts/*.prompt.md"
        schema:
          frontmatter-closed: true
          frontmatter:
            "description?": nonEmpty
            "input?": string
            "allowed-tools?": '[...string]'
            "model?": nonEmpty
            "argument-hint?": nonEmpty

    Every key is optional (trailing ?), so the kind constrains which keys may appear without demanding any of them.

    # Layering

    When two kinds claim one file, their schemas compose. The composed front matter accepts a key either kind declares, and stays closed unless every kind that declares a frontmatter: map opens it. One kind’s frontmatter-closed: false cannot loosen another kind’s contract. A kind with no frontmatter: map of its own — a sections-only or filename-only kind — casts no vote either way.

    A kind that declares frontmatter: but leaves frontmatter-closed: out counts as closed, since closed is the default. So to open a file’s front matter, set false on every kind with a frontmatter: map that claims the file. When one kind opened it and another kept it closed, the undeclared-key report says so:

    extra: got 1, expected not declared in schema
      (front matter stays closed: every kind composed
      for this file must set `frontmatter-closed: false`
      to open it)

    mdsmith kinds resolve <file> lists the kinds that claim the file.

    A proto.md cannot set frontmatter-closed:, so a proto.md that declares front matter always counts as closed. When one keeps the file closed, the hint names it and asks you to declare the key there.

    Under extends: the rule is different: the child’s explicit value wins, and a child that says nothing inherits the parent’s. Inheritance is a relationship the author wrote on purpose; composition merges kinds that met on one file.

    # fmvar in path and filename globs

    \#(fmvar(name)) — the same helper the regex: matcher uses for heading text — also works in a schema’s filename: globs and in a kind’s path-pattern:. Both resolve the reference against the document’s own front matter before matching.

    Only a well-formed \#(fmvar(<path>)) is interpolated. Any other \#( keeps its old glob meaning: an escaped # followed by (. That covers \#(digits), the matcher’s other helper, which has no capture group to read back in a glob. So a glob written before interpolation existed still loads and matches the same files: notes/\#(draft)*.md still accepts notes/#(draft)-1.md.

    On Windows a path-pattern: may use \ as its separator, as any pattern can. mdsmith reads each \ as /, except the \ that opens a reference.

    Under the cue-frontmatter placeholder, a template’s front-matter values are CUE constraints such as name: string, not data. A reference then matches any non-empty text within one path segment, and the rest of the glob is still checked.

    The resolved value’s glob metacharacters are escaped, so it matches literally. A name of a*b matches the directory a*b and not axxb — the glob analogue of the regex matcher’s regexp.QuoteMeta. The mismatch hint shows the value as written (a*b), not in its escaped form.

    A non-string value is substituted in the form YAML decoded it to. An unquoted timestamp at midnight UTC substitutes as its date: date: 2026-01-02 gives 2026-01-02, so \#(fmvar(date))-*.md accepts 2026-01-02-release.md. Any other timestamp substitutes as RFC 3339, which need not be its source text: 2026-01-02 10:00:00 gives 2026-01-02T10:00:00Z. The dates and timestamps guide lists every rule. A frontmatter: constraint checks that same text, so the date needs no quotes: date: string accepts date: 2026-01-02, and date: =~"^2026-" matches it. A quoted date: "2026-01-02" checks and substitutes the same way. YAML reads 0012 as the number 10 and 1.10 as 1.1, so quote such a value (id: "0012") to keep the digits as written.

    One byte cannot be escaped into a literal: / stays a path separator whatever precedes it. A reference stands for a single path segment, so a value containing / is reported rather than silently spanning two directories.

    Do not put a reference inside a character class ([...]). A class matches one byte, not a value. A value such as ! also leaves an invalid class ([!]); the diagnostic then names the syntax error.

    # Example: an APM skill

    APM’s .apm/skills/<name>/SKILL.md requires the name field to equal the directory name. A static glob cannot express that:

    kinds:
      apm-skill:
        path-pattern: ".apm/skills/\\#(fmvar(name))/SKILL.md"

    With name: code-review the kind accepts .apm/skills/code-review/SKILL.md. Under .apm/skills/reviewer/SKILL.md it reports the mismatch and shows the expansion:

    path: got ".apm/skills/reviewer/SKILL.md", expected
    path matching glob .apm/skills/\#(fmvar(name))/SKILL.md
      (with front matter applied: .apm/skills/code-review/SKILL.md)

    # Unresolved references

    A document with no name field at all does not match some degenerate path. It reports which reference could not be resolved:

      (`fmvar(name)`: frontmatter value missing)

    A name that is present but empty reports this instead:

      (`fmvar(name)`: frontmatter value is empty)

    Substituting nothing would leave the path .apm/skills//SKILL.md.

    A name that holds a list or a map is present but cannot name a path segment:

      (`fmvar(name)`: frontmatter value is a list or map,
      not a scalar)

    A name of a/b carries a path separator, which would let one reference cover two directories:

      (`fmvar(name)`: frontmatter value "a/b" contains a
      path separator; an interpolated value must name a
      single path segment)

    A filename: list is still an OR list. An entry whose reference will not resolve drops out. A sibling glob may still accept the basename. The unresolved message shows only when no entry matched.

    A malformed reference is not a config error. Take fmvar(my-key), whose key needs quotes. The same text was a valid glob before, so it still loads. It is matched as literal text. When the path then does not match, the hint says why:

      (`\#(fmvar(my-key))` is matched literally, not
      interpolated: `fmvar(my-key)`: invalid frontmatter
      path (non-identifier keys must be quoted, e.g.
      `fmvar("my-key")`))

    # See also

    • Section schema — the heading: grammar and the rest of the schema-level fields.
    • Schema field types — the named shortcuts (nonEmpty, date, …) the examples above use.
    • File kinds — how a file is assigned to a kind in the first place.