Theming
The consumer contract between MySTRA and a rich MyST theme, for theme authors.
This page is the consumer contract between MySTRA and a rich MyST theme. Report authors do not need it: MySTRA emits readable, neutral MyST nodes that work with the stock themes. A dedicated theme may add kind styling, hover/focus previews, record dialogs, artifact previews, and provenance navigation without reopening or re-resolving the ASTRA project.
The integration has two layers:
- visible MyST nodes carry small recognition markers and canonical identity;
- a hidden, versioned carrier contains the resolved SDK bundle and rewritten artifact resources used to enrich those nodes.
Recognition markers
MySTRA exposes recognition classes for placed presentation surfaces and data.astra metadata for
joinable identities. These signals overlap but are not identical. They are MyST AST node.class and
node.data.astra fields, not browser DOM className or dataset values. Match class tokens in
either a string or string-array representation; an author's :class: option may add more tokens.
The stable class markers are:
| Class | Element |
|---|---|
astra-decision | A decision block |
astra-output, plus astra-output--figure / --table / --metric / --data / --report | An output block, subtyped |
astra-finding | A finding block |
astra-prior-insight | A prior-insight admonition |
astra-input / astra-inputs / astra-outputs | Single-input table / registries |
astra-option | An option heading |
astra-subanalysis | A sub-analysis summary card |
An individual record block carries identity on its carrier. Registry tables carry their recognition
class on the table and record identity on each row, not on the registry as a whole. An individually
placed evidence surface carries owner identity on its first rendered node but has no dedicated
stable block class. An author's :label: may replace a fallback MyST identifier, but it never
changes any ASTRA identity.
Inline tokens are neutral spans carrying astra-ref and astra-ref--<kind> classes. The possible
kinds are decision, output, finding, prior_insight, analysis, input, option,
evidence, and value.
Record identity
Record references, placed record blocks, and input/output registry rows carry:
{
kind: 'input' | 'output' | 'decision' | 'finding' | 'prior_insight';
id: string;
canonicalPath: string;
}canonicalPath is the stable lookup key from @astra-spec/sdk. Always use it instead of deriving
an ASTRA id from a MyST identifier or an author's label. Option and evidence surfaces carry the
child identity and point back to their owning record:
{
kind: 'option' | 'evidence';
id: string; // child id
canonicalPath: string; // owning decision or insight record
}Analysis identity
Analysis navigation uses the analysis index rather than the record index:
{
kind: 'analysis';
id: string;
analysisPath: string; // '$' identifies the root analysis
href?: string;
}An analysis identity in data.astra never uses the canonicalPath field; analysisPath joins it
to the SDK's analysis index. An inline analysis reference has href only when exactly one concrete
page in project.toc maps to that analysis through its filename or astra_scope. An unlisted,
invalid, ambiguous, or pattern-expanded mapping omits href; themes must not invent a route from
analysisPath. Placed sub-analysis cards carry analysisPath but leave navigation to the host.
Value identity
Value tokens retain the owning record's canonicalPath and add the context needed to explain the
displayed value:
// Selected decision option
{
kind: 'value'; id: string; canonicalPath: string;
type: 'decision'; selection?: string;
}
// Selected table cell
{
kind: 'value'; id: string; canonicalPath: string;
type: OutputType; product?: string; col: string; filter?: string;
}
// Parsed scalar metric
{
kind: 'value'; id: string; canonicalPath: string;
type: 'metric'; product?: string; unit?: string;
}Optional strings are omitted when unavailable. filter is the normalized key=value, … selection
used to choose a row. A theme should treat the visible MyST children as the authoritative formatted
value and use this metadata only for context and record lookup.
Publication transport
MySTRA resolves the complete project once through @astra-spec/sdk and, on each successfully
resolved page, adds the canonical ResolvedAnalysisBundle. The data lives on a hidden
div.astra-publication-bundle node:
interface AstraPublicationData {
schemaVersion: 'astra-publication-bundle.v1';
activeAnalysisPath: string; // '$' identifies the root analysis
bundle: ResolvedAnalysisBundle;
}The value is stored at node.data.astraPublication. The transport version and the SDK document
version are intentionally separate: the former versions the MySTRA-to-theme envelope, while the
latter versions the resolved ASTRA data. activeAnalysisPath is presentation context for the
current page; bundle always contains the whole resolved project.
Treat carrier data as unknown at a JSON or server boundary. Validate the envelope, use the SDK's runtime decoder for the bundle, and only then build the derived indexes:
import {
indexAnalysis,
parseResolvedAnalysisBundle,
} from '@astra-spec/sdk';
const TRANSPORT_VERSION = 'astra-publication-bundle.v1';
function object(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
function decodePublication(value: unknown) {
const candidate = object(value);
if (
!candidate ||
candidate.schemaVersion !== TRANSPORT_VERSION ||
typeof candidate.activeAnalysisPath !== 'string'
) return undefined;
try {
const bundle = parseResolvedAnalysisBundle(candidate.bundle);
const index = indexAnalysis(bundle.document);
if (!index.analysisByPath.has(candidate.activeAnalysisPath)) {
return undefined;
}
return {
bundle,
index,
activeAnalysisPath: candidate.activeAnalysisPath,
};
} catch {
return undefined;
}
}The decoder validates already-deserialized transport data; it does not read files or repeat project
resolution. A server receiving JSON should call JSON.parse first and pass the result to the
decoder.
Discovering the carriers
Carriers may be inside any MyST subtree, so traverse child arrays rather than assuming the publication carrier is a direct root child. Preserve sibling relationships while traversing:
- find a
divwhose class list containsastra-publication-bundle; - read and decode its
data.astraPublicationvalue; - if the immediately following sibling is
div.astra-publication-resources, consume its resource links; - continue looking if the candidate is malformed or its active analysis does not exist.
This is the approach used by astra-theme: an invalid or unsupported carrier does not break the
page; the theme simply leaves the neutral MyST rendering in place.
Rejoining artifact URLs
SDK bindings contain canonical project-relative artifact paths, not the final browser URL. MySTRA
therefore emits an optional sibling div.astra-publication-resources. Each child is a MyST link
node with:
{
type: 'link';
static: true;
url: string; // rewritten by MyST's asset pipeline
data: {
astraArtifact: {
outputPath: string;
cacheToken: string;
};
};
}Build a map of SDK bindings by outputPath. Accept a rewritten URL only when both outputPath and
the opaque cacheToken match the corresponding binding. Use the rewritten link.url for an image,
fetch, or download; retain binding.path only as project metadata. Missing, stale, or mismatched
links must render as an unavailable artifact rather than falling back to a guessed path.
If a URL can become an href, src, or fetch target, accept site-relative URLs or explicit
HTTP(S) URLs and reject active schemes, control characters, and protocol-relative URLs.
Looking up records and analyses
Once the bundle is decoded and indexed, renderer joins are direct:
const active = index.analysisByPath.get(activeAnalysisPath);
const metadata = node.data?.astra;
const canonicalPath = typeof metadata?.canonicalPath === 'string'
? metadata.canonicalPath
: undefined;
const record = canonicalPath
? index.recordByPath.get(canonicalPath)
: undefined;
const owner = canonicalPath
? index.analysisByRecordPath.get(canonicalPath)
: undefined;
const analysis = typeof metadata?.analysisPath === 'string'
? index.analysisByPath.get(metadata.analysisPath)
: undefined;Check the metadata shape before each lookup and preserve the original MyST children when a lookup fails. The decoded document carries universe metadata; its records carry the resulting activity and selected-option state plus alias, provenance, and evidence relationships. The index adds canonical record, analysis, and ownership lookups. A theme should not reconstruct any of them.
Building a React/MyST theme
The current astra-theme integration is deliberately narrow and is a useful template for another
theme server:
- wrap the rendered article surface in a publication provider that discovers and decodes the page carrier;
- keep the decoded bundle, SDK index, active analysis, and artifact-resource map in that provider;
- add renderer overrides only for ASTRA nodes that need interaction, while delegating their visible children to the normal MyST renderers;
- resolve an inline trigger through
canonicalPath, show a compact hover/focus preview, and open the full record dialog on click; - reset open detail state when the publication object changes during client navigation.
@astra-spec/ui supplies positioning-agnostic RecordPreview content, RecordDialog detail
surfaces, the PreviewPopover interaction primitive, and detail-stack state. A host remains
responsible for MyST citation rendering, artifact loading, route navigation, portal
colour-scheme/brand scope, and the callback that opens a canonical record. This keeps the UI package
usable by non-MyST applications and keeps a theme server free of ASTRA source/project parsing and
resolution logic.
For upstream maintainability, block carriers can remain entirely on the stock MyST renderer: the neutral block already contains the figure, table, decision, finding, or summary content. The theme may add a small wrapper or provenance affordance without replacing that content. Inline ASTRA spans are the primary renderer seam for previews and record dialogs.
Citation registration
When the resolved project cites literature, MySTRA emits div.astra-cites containing one narrative
and one parenthetical hidden MyST cite node per unique normalized DOI. Keep this carrier in the
complete tree while MyST runs citation resolution so those sources reach its bibliography pipeline.
Read evidence and DOI relationships from the SDK bundle, then use the host's normal MyST citation
renderer for formatted author–year content; do not maintain a second bibliography implementation in
the theme.
Failure behaviour
A theme server should degrade locally and preserve the publication:
- unsupported transport or SDK schema: render the neutral MyST page;
- missing
canonicalPathor failed lookup: keep the original node children; - missing or stale artifact resource: show an unavailable preview;
- absent analysis
href: show a preview or label without invented navigation; - dialog or preview state during page navigation: close or reset it.
No browser-side filesystem access, astra.yaml parsing, project resolution, or parallel provenance
model is required. All semantic information comes from the decoded SDK bundle; all browser artifact
URLs come from MyST-rewritten resource links.
Anchors
Rendered elements retain local fallback identifiers for ordinary MyST rendering. Authors who need a
project-wide cross-reference should set the directive's explicit :label: and use MyST's standard
{ref} role. A theme must not treat an author-controlled label as an ASTRA identity or invent a
globally unique anchor from an unscoped record id.