Components

A component is everything that produces one tag: its Markdown syntax, its build-time HTML, its styles, and its custom element. Any subset of that — a component can be a template and nothing else, or a custom element and nothing else.

A component is a directory

The directory is named after the tag, and the files it contains are the declaration. There is nothing to register.

components/
	my-thing/
		index.js      what the component does at build time
		template.njk  build-time rendering
		element.js    custom element
		style.css     styles, included on every page
		shadow.css    styles adopted into the element's shadow root
		components/   sub-components

Every file is optional. A component with only a template.njk is a template; a component with only an element.js is a custom element.

Docspire looks for components in three places, and merges them file by file, in this order:

  1. Docspire’s own
  2. components/ inside each plugin
  3. components/ next to your docspire.js, plus any directories in the components option

So dropping components/callout/template.njk into your site changes how callouts render, and keeps everything else about them — their Markdown syntax, their data, their styles.

Every file is replaced, though, not merged: your template.njk is the template, and a style.css of your own is the stylesheet — the one it overrode is not loaded at all. To add to a component’s styles rather than replace them, put the CSS in a stylesheet of your own. index.js is the one exception: every source’s spec is applied in turn, key by key, so overriding a template keeps the plugin’s data().

Name the directory +callout instead and it is an override, in the sense the Plugins page describes: applied after every plain source whichever order they loaded in, and skipped altogether if nothing declares a callout. That is what a plugin overriding another plugin’s component wants, and the reason a plugin can ship one speculatively.

A component’s index.js is the one file that merges rather than being replaced: every source’s spec is applied in turn, key by key, so overriding a component’s template does not discard the plugin’s data().

A component that ships an element.js must have a hyphen in its name, because that is what a custom element name requires. One that does not can be called anything: <callout> never reaches the browser.

Rendering with a template

template.njk replaces the tag it matched. The template receives the page’s data, plus:

So a component that renders itself into a <div> is one line:

<div class="my-thing">{{ content | safe }}</div>

Since the template replaces the whole node, it emits the tag itself if the tag should ship. To keep the tag and its attributes and just do something with the content:

<my-thing {{ attrs | attr }}>
	<div class="my-thing-body">{{ content | safe }}</div>
</my-thing>

That is not a recursion hazard: what a template produces is inert to the component that produced it. Content the author wrote is still expanded, so components nest — a callout inside a callout, a code block inside either.

Precisely: the outermost instances of the component’s own tag in its output are left alone. Everything else in that output is expanded as usual — a nested instance the author wrote, and any other component’s tag, including tags you did not know had rules of their own.

That last part is the point. A component can be about built-in tags — a section component rewrites every <section> on the page — so composing components should not require knowing which tags something else expands.

Order does not come into it either. Components are expanded until nothing changes, so a component’s output is expanded by every other component — including ones that had already run by the time it produced anything.

A component can recurse — a nested instance in its own output is expanded, since only the outermost ones are inert — but not far: about ten rounds, with no way to raise it. So recursion here is a way to nest a few levels deep, not an algorithm to run. Nothing here can hang a build. Output that keeps producing more of itself, and two components that produce each other’s tags, both fail the build and name what was still going round.

Note

The attr filter takes a map of attributes and prints them, skipping the ones whose value is false, null or undefined. An empty string prints as a bare attribute, with no value. {{ isOpen | attr("open") }} prints a single attribute, and only when the value is truthy.

Shaping the data a template gets

data(node, context) in index.js returns what the template should render with.

// components/my-thing/index.js
export default {
	data (node, { attrs, extract }) {
		return {
			tone: attrs.tone ?? "plain",
			title: extract(node, this.titleTag),
		};
	},
};

extract(node, selector) takes the first matching direct child out of the node and returns its contents as HTML — the wrapper element itself is dropped — so it becomes data rather than staying in content. It returns undefined when nothing matches. context also carries options, the page’s data, and helpers for reading the node: matches, getChildren, getTextContent, parse, serialize, and expand.

data() is called with the component as this, so this.tag and this.titleTag are how you refer to your own tag without hardcoding a name someone may have changed.

Rendering with JavaScript

Some components have nothing to template — they restructure markup that is already there. transform is a map of selectors to functions, and returning a new node replaces the matched one:

// components/code-block/index.js
import { getChildren } from "docspire/util/transforms";

export default {
	transform: {
		pre (node) {
			let language = node.attrs?.class?.match(/language-(\w+)/)?.[1];

			if (!language || getChildren(node, "code").length === 0) {
				return node;
			}

			return { tag: "code-block", attrs: { language }, content: [node] };
		},
	},
};

Transforms run before the template pass, and their job is to produce instances of the component’s tag. Whatever they produce is then rendered by template.njk, exactly as if you had written the tag by hand. If a transform generates markup that other components should get a look at, hand it to expand():

transform: {
	"my-thing" (node, { parse, expand }) {
		return { tag: "div", content: expand(parse("<callout>Generated</callout>")) };
	},
},

Markdown syntax

markdown gives the component a Markdown form. Its only job is to emit the component’s own tag, so ::: note and <callout type="note"> are the same thing from there on.

export default {
	markdown: true,    // ::: my-thing … :::
};
markdown What you get
true A block form named after the tag
"note" or ["note", "tip"] Block forms with those names; a name that differs from the tag arrives as type
{ syntax, name, names, render, … } One form, spelled out — below
A function (mdLib, context) — any markdown-it plugin at all, as long as it emits your tag
An array Several of the above, so one component can have more than one form

A form is two decisions: which syntax it takes, and what names it answers to.

export default {
	markdown: {
		syntax: "inline",   // "block" (the default) or "inline"
		name: "badge",      // or names: ["note", "tip"], or both; defaults to the tag
	},
};

A block form is written on lines of its own, an inline one mid-sentence:

::: my-thing
Block form.
:::

Some :badge[inline **text**]{.shiny} in a sentence.

Either way what it emits is the component’s own tag with those attributes on it, so it is the same instance the other form would have produced — same data(), same template, same expansion.

A form is one syntax or the other, never both: what reads as a block does not read in the middle of a sentence, and a component meant to be used inline should render inline markup — a <span>, not a <div>. The two also differ all the way down — names, renderer, options — so a component that genuinely reads either way declares two forms, and each is complete on its own terms:

markdown: [
	{ syntax: "block", name: "aside" },
	{ syntax: "inline", name: "aside", render: myInlineRenderer },
],

name and names are the same thing, singular and plural, and either may be a function of the plugin’s options — that is how callouts get one form per configured type. Leave both out and the form answers to the component’s own tag.

Rendering a form yourself

render is the form’s renderer. It is markdown-it’s, so what it is handed and what it does with it differ between the two syntaxes, as everything below syntax does — but there is one default, for both, to build on rather than replace:

import { render as renderDefault } from "docspire/markdown";

A block form’s renderer is handed markdown-it-container’s arguments and returns HTML:

export default {
	markdown: {
		name: "note",
		render (component, name, config, tokens, idx, options, env) {
			let html = renderDefault(component, name, config, tokens, idx, options, env);
			return html.replace(`<${component.tag}`, `<${component.tag} data-mine`);
		},
	},
};

An inline form’s is handed the directive and pushes tokens rather than returning anything:

export default {
	markdown: {
		syntax: "inline",
		render (component, name, config, directive) {
			renderDefault(component, name, config, {
				...directive,
				attrs: { ...directive.attrs, "data-mine": "yes" },
			});
		},
	},
};

Anything else in the object is markdown-it-container options — marker, minMarkers — which belong to the block form, and passing one to an inline form is an error rather than a setting that quietly does nothing.

Any other Markdown syntax

The function form is the way out of these two entirely, for a component whose Markdown syntax is some other markdown-it plugin. Use the use() it is given rather than mdLib.use(): a plugin that two components both want is applied once, where markdown-it would happily apply it twice and render everything it produces twice over.

import markdownItFootnote from "markdown-it-footnote";

export default {
	markdown: (mdLib, { use }) => use(markdownItFootnote),
};

Names are shared

Names are shared across every component on the site, so two of them cannot both claim ::: note. That is an error rather than a silent winner, and you settle it without touching either plugin: add components/<tag>/index.js to your own site giving one of them a different name, or markdown: false to leave that component without a Markdown form.

Block and inline are separate namespaces, though, so ::: note and :note[…] can belong to different components.

Your index.js merges into the component key by key, so what you leave out is whatever the plugin said. To change part of what a key holds rather than all of it, export a function of the spec so far:

// components/beta-box/index.js
export default spec => ({ ...spec, markdown: "beta-note" });

That way a component whose markdown was a list of several forms, or whose data() you did not mean to touch, keeps everything you did not name.

Titles and flags

Words after a block form’s name become the title, which arrives as a <my-thing-title> child — or whatever the component’s titleTag says. Words separated by dots become flags, which arrive as a flags attribute:

::: my-thing.collapsed A title
Content
:::

Custom elements

element.js default-exports the class, and nothing else:

// components/my-thing/element.js
import DocspireElement from "docspire/element";

export default class MyThing extends DocspireElement {
	static template = `<slot></slot>`;

	connectedCallback () {
		super.connectedCallback();
	}
}

Docspire generates a loader that imports the module only on pages that actually contain the tag, then defines it — so do not call customElements.define yourself. If the component has a shadow.css, the loader adopts it into the shadow root.

A component can be both built and interactive: the template renders the light DOM at build time, and the element upgrades it in place when it loads. That is how the page outline works — the list is in the HTML, and the element only adds scroll tracking.

Styles

style.css is included on every page, like any other stylesheet. shadow.css is adopted into the element’s shadow root. Both are ordinary CSS in the output, so style transforms and style aliases apply to them, whoever registered those.

styleAs says that part of your component should look like something the theme already styles:

export default {
	styleAs: { ".my-thing-title": "h4" },
};

Every rule that styles h4 now styles .my-thing-title too, with h4’s specificity — including rules from stylesheets you do not control. Callouts use exactly this: callout titles are headings at heart.

Sub-components

A components/ directory inside a component holds tags that only mean something in that context:

components/my-thing/
	template.njk
	components/
		my-thing-label/
			template.njk

my-thing-label is expanded inside a my-thing, and left alone anywhere else.

Because it only means anything in there, it is told which instance it is in. context.parent is the parent instance: its tag, the node as authored, its attrs, and the data its data() returned — so a label can render from the thing it is inside rather than from attributes repeated on itself:

// components/my-thing/components/my-thing-label/index.js
export default {
	data (node, { parent }) {
		return { tone: parent.data.tone };
	},
};

parent.data is whatever the parent’s data() returned, so it is there only when the parent has a template.njk — that is the pass data() runs in. A parent without one still passes its tag, node and attrs.

parent.parent walks further up, for a sub-component of a sub-component.

Sub-components are scoped at build time: what the parent’s scope decides is whether the tag is expanded, not what it means at runtime. A custom element has no such scope — it is defined for the whole page — so a sub-component cannot have an element.js.

Scoped custom element registries do not change that, even where they have shipped: they scope a definition to a shadow root, and what a component renders at build time is light DOM, which always resolves against the global registry. So a component that needs an element belongs in a components/ directory of its own, where being global is what it says it is — at the cost of its tag being expanded wherever it appears.