Element metadata in XML and HTML
Some documents keep their metadata in attributes. Many keep it in elements. An
XML article has a <title> child. A DITA topic has a full <prolog>. An HTML
page’s <title> may be the only place the page names itself. docmeta reads
both.
The rule
Section titled “The rule”A value held in an element is keyed <immediate parent>.<element name>. The
containing element is the namespace.
<article><byline>Ada Lovelace</byline></article> → article.byline<prolog><author>Ada Lovelace</author></prolog> → prolog.author<critdates><created date="2026-01-15"/></critdates> → critdates.created<head><title>Docs</title></head> → head.titleExisting flat keys do not move. Root attributes, <meta> tags,
<othermeta> entries and HTML’s <title> keep the names they have always had.
A document can carry the same fact in two places, and both are validated.
Neither wins. A value that wins silently discards the other, and the discarded
one is exactly the one nobody is checking.
What each format lifts by convention
Section titled “What each format lifts by convention”| Format | Region | Yields |
|---|---|---|
| Generic XML | the root’s direct text-bearing children | <root>.<child> |
| HTML | text-bearing <head> children |
head.title |
| DITA topic | <prolog> and its nested containers |
prolog.*, critdates.*, metadata.*, prodinfo.* |
| DITA map | <topicmeta> and its nested containers |
topicmeta.*, critdates.*, … |
Two exclusions keep the key set honest, and both are why the convention can work without a hardcoded ignore list:
- An element with element children is structure, not a value.
<body>full of<p>is a container. Lifting it would concatenate a document’s prose into one key. - Whitespace-only text is indentation, not content. Every container in an indented file holds a newline and some spaces.
HTML additionally skips <script> and <style>: both are text-bearing and
neither is metadata. Void elements such as <meta>, <link> and <base> hold
their value in an attribute, and are not lifted by convention. <link> repeats,
and collapsing a canonical URL together with three stylesheets would discard the
rel that told them apart. Reach those with a config path.
Lists, and why generic XML always uses one
Section titled “Lists, and why generic XML always uses one”Plain XML states no cardinality, so docmeta cannot know whether a second <tag>
is coming. A type that changed with document content could not be written
against, so the convention commits to a list:
"article.title": { "type": "array", "items": { "type": "string" }, "maxItems": 1 }maxItems: 1 is how a schema says “there should be exactly one”, which is a
check that was not expressible before.
Where a content model does state the cardinality, the key follows it.
head.title is a scalar because HTML permits one <title>;
DITA’s keys are typed element by element
because the OASIS content model says which repeat.
Key types
Section titled “Key types”Check one thing before writing a schema against a lifted key. Is it a list or a scalar? It depends on the format, and getting it wrong produces a confusing failure. The field is present and has a value, and the type is simply not the one you declared.
| Format | Key type | Decided by |
|---|---|---|
| Generic XML | always a list | nothing, since XML states no cardinality |
| HTML | scalar for head.title, list otherwise |
the HTML content model (one <title>) |
| DITA | per key: prolog.author is a list, prolog.source is a scalar |
the OASIS content model (author* vs source?) |
Any elements: config path |
always a list | nothing, since a path states no cardinality |
"article.byline": { "type": "string" } // always fails"article.byline": { "type": "array", "items": { "type": "string" } } // right"article.byline": { "type": "array", "items": { "type": "string" }, "maxItems": 1 }The first form fails on every document, including one with exactly one
<byline>. Nothing warns you, because it is valid JSON Schema and simply never
matches.
Run docmeta get against a real file to see the shape a key actually has before
writing a schema for it:
docmeta get article.byline docs/api.xml -f jsonelements: reaches what the convention does not
Section titled “elements: reaches what the convention does not”The convention stops short on purpose. Naming a path is how a repo says “this one, specifically”.
elements: - article/byline/author # → byline.author - html/head/link@href # → head.linkoverrides: - files: "specs/**" elements: - spec/revision # → spec.revisionA path is slash-separated and absolute from the document root. It is a
deliberate subset of XPath’s child axis, so predicates and axes are not coming.
@attr selects an attribute instead of the element’s text, which is the only
way to reach a void element.
The key is still derived by the rule and is never spelled in config. Two ways to name one key is the ambiguity this design removes.
Four behaviours worth knowing:
- Config extends the convention. An
elements:path whose key the convention or a content model already filled has no effect. That is what stops namingconcept/prolog/sourcefrom retyping it out of the scalar its content model says it is. A path cannot be used to override a key, only to add one the convention does not reach. elements:accumulates whereschemas:replaces. A schema set is a complete statement about how a file is judged. Element paths are a list of extra places to look, so the repo-wide list and every matching override all contribute.- A named path lifts an empty element, as
[""]. The convention skips one; someone who names it wants “present but empty” checked. - Paths are validated when the config loads. A typo left to extraction would match nothing and produce no key. The check it was meant to run would silently never happen.
What a write does
Section titled “What a write does”fill writes element metadata back where it was read from. That is not a
nicety. If a value were written anywhere else, the field would stay invalid. The
next run would propose it again, and CI would never go green.
Replacing the text between an element’s tags, or the value inside an attribute’s quotes, cannot change whether the document is valid. It changes content, not shape. So it works in any dialect, including ones docmeta knows nothing about.
Adding an element that is not there means knowing where the element is legal. docmeta does that only for DITA, whose content model can answer. It places the new element, and any containers it needs, at the position the model requires.
Updating works for XML, DITA and HTML alike. Creating is DITA-only. Elsewhere a
key with no element to update falls back to the format’s own channel. That is a
root attribute in XML, or a meta tag in HTML.
A key backed by N elements receiving M values is only unambiguous when the
two match: each value replaces one element, in document order. Fewer would mean
deleting elements and more would mean creating them, so both are refused by
name. A fill that quietly dropped an <author> is worse than one that
declines.
Addressing a lifted key from get
Section titled “Addressing a lifted key from get”Both spellings work:
docmeta get article.title docs/api.xmldocmeta get /article.title docs/api.xmlDot-notation tries nested traversal first, which is right for a frontmatter
object like author.name. It falls back to the literal key when that misses.