@x12i/fieldkit
v2.13.0
Published
Generic field-rendering toolbox for React: pass a record and a JSON FieldMap, get a painted record. No domain vocabulary inside; it all lives on the map.
Readme
@x12i/fieldkit
A generic field-rendering toolbox for React. A host passes a plain record and a
FieldMap, and the kit paints the record. The kit has no product, content-type, or
domain vocabulary inside it. Every label, tone, banner sentence, and key-name
convention lives on the map the host builds.
npm install @x12i/fieldkitimport { RecordView } from '@x12i/fieldkit';
import '@x12i/fieldkit/styles.css'; // or render <style>{stylesheet}</style>
<RecordView data={record} map={map} />;
<RecordView data={record} map={map} view="markdown" />;
<RecordView data={record} map={map} markdown={{ copy: true, download: true }} />;data is a JSON record, an array of records, a JSON string of either, or a markdown
document. Fieldkit tells those apart. An object, an array, or a string that
parses as one is JSON, and map paints it. Any other string is markdown: the
card reads the document (the first # is the title, each ## section is a
field, and lists, tables, and json fences become those kinds), and
view="markdown" shows the source. map may be omitted for a markdown
document.
view is card (default) or markdown. The markdown view paints the generated
document for JSON, and the source for a markdown string. custom,
renderHeader, and a React banner apply to the card. The document uses the
evaluated banner text.
markdown.copy and markdown.download are independent. When either is set, an
icon sits at the top right of the card and the document. Copy writes the
markdown to the clipboard. Download saves a .md file named from the title, or
record.md. They export the original markdown when that was the input, and the
generated document otherwise.
An array paints one table and nothing else: no banner, title, status, or diagnostics, and no heading above the table.
fields are the columns, in that order, with the same cell kinds as a rows entry.
A key those columns do not name is still a column. With fields: [], columns are
inferred the same way as an undescribed array of objects (id, name, status, kind,
type, then the rest). labels, vocabulary, formats, acronyms, emptyLabel,
storageKeys, and omitIds still apply. Descriptors are not merged for an array.
An empty array paints emptyBodyNote and no table. A mixed array, or one that is
not all objects, paints as JSON.
The map
A FieldMap is plain JSON. It contains no functions, so a host can compile it,
cache it, or ship it over the wire.
| Field | Meaning |
|---|---|
| fields | Ordered entries (required). |
| titlePaths | Dotted paths; the first non-empty string becomes the header title. |
| statusKeys | Header pills, in order. null, undefined, and "" are skipped. These keys are not painted again in the body. |
| hideKeys | Present on the record but never painted, not even under Additional fields. |
| identityKeys | Painted by the header and removed from the body. |
| acronyms | Adjust the built-in acronym dictionary (see Labels). |
| formats, locale, timeZone | Opt-in display of dates, durations, links, and emails (see Value formats). |
| vocabulary | Value → { label?, tone?, toneWhen? }. |
| conventions | Key or value patterns that pick a kind for keys fields does not name. |
| banners | Ordered rules; the first match paints one banner above the header. |
| emptyLabel | Placeholder text. Default Not provided. |
| storageKeys | Keys that name the stored copy of a record rather than its subject. Built in: instanceId, _id, __v, _rev. Listed keys are never inferred or merged from descriptors, at any depth. !key removes a built-in and paints that key. A field entry that names one still paints. |
| omitIds | Inferred identifier keys are omitted: id, uuid, uid, recordId, userID, recordUuid, user_id, asset_uuid. Not words that merely end that way (valid, grid, identity, cveIds). false paints every id, including instanceId. A list of paths is painted anyway: technique.id matches that property at any depth, actions[].technique.id only under actions. A field entry, an identity key, or a descriptor field still paints its id. |
| emptyLeftovers | 'omit' drops keys no entry names whose value is null, "", or []. Default 'placeholder' paints them with emptyLabel. Declared entries keep their own empty policy. |
| emptyBodyNote | Shown in .fk-content-note when no content row paints. |
| diagnosticsOpen | Start the diagnostics <details> open. |
| omit | Parts to leave unpainted when they exist: banner, status, identity, content, diagnostic / diagnostics (the whole diagnostics disclosure, additional fields included), additional (leftovers only). Unknown names are ignored. RecordView and recordMarkdown take the same list as an argument; the two lists are combined. |
| labels | Final field labels by key or dotted path: { transformers: 'Models', 'meta.owner': 'Owner' }. A path beats a bare key. They win over entry label, descriptors, and inference, and they also rename table column headers. A labels argument on RecordView or recordMarkdown wins over the map's. |
| sectionLabels | Titles for the kit's own sections: { diagnostics: 'Debug', additional: 'Other data' } (diagnostic is accepted too). An argument of the same name wins over the map's. |
Field entries
{ key: 'summary', as: 'lead' }
{ key: 'stepId', as: 'mono', bucket: 'diagnostic', label: 'Step' }
{ key: 'sessionCount', as: 'stat', showWhen: { path: 'coverage', eq: 'COMPLETE' } }
{ key: 'notes', empty: 'omit' }keyis the row identity (data-field-key) and the default source path.pathoverrides the source; child entries read from their parent object,$.xreads from the record, and$is the record itself.asis the kind. With noas, an empty value paintsNot providedand anything else gets its kind frominferRule. With an explicitas, the kind is kept even for empty values.bucketiscontent(default),diagnostic, oridentity.emptyisplaceholder(default for top-level entries) oromit(default for layout children).showWhentakes{ path, eq },{ path, neq },{ path, in },{ path, present },{ path, absent },{ deepScan },{ all: [...] }, or{ any: [...] }. Comparison is===.onlyis a type guard (string,number,boolean,array,string[],object). A value of any other type is treated as absent.whenSourceMissing: 'render'keeps the row, painted asNot provided, when the source is missing or a layout has nothing to show.placeholder: { text, tone? }is painted in the entry's kind fornullor a missing value.prefixandaffix: 'quote'decoratetext,lead, andmono.consumeslists raw keys the entry accounts for, so they don't reappear under Additional fields. The default is the root key of the source path.
A present key that is not named, hidden, identity, consumed, a storage key, or an
inferred identifier (omitIds, on by default) is inferred: the first matching
conventions entry, otherwise the key and value rules below. When the map declares
no content row, inferred keys are the body. Otherwise they render under Additional
fields. A key that is absent from the record produces no row at all. The kit never
invents 0, false, or No.
Derived values
{ key: 'hopCount', as: 'stat', derive: { op: 'count', path: ['hops', 'pathHops', 'nodes'] },
omitZero: true, empty: 'omit', consumes: [] }count, first, and join (separator, default ", ") are the only ops. With a
list of paths, the first non-empty array wins.
Layouts
Layouts describe nested shapes as data. Each one paints only the kinds below, and any child may itself be a layout.
flatten: one object, one sub-field per own property (orderfirst). Each property uses itsitemoverride, elseeachdefaults, elseconventions, elseinferRule.labelPrefixandlabelCase: 'lower'shape the humanized labels.pick: an ordered list ofpicks.scalaris the kind used when the source is not an object. Nestedpickandflattenchildren are spliced into the parent list.rows: a map or an array of objects. Each object becomes one compact row ofcells. Map rows are labelled with the humanized key, inorderfirst.columnspaints a table in that order instead.blocks: an array of objects, each a stack ofitemchildren. Children show a label only when they declare one.
structure: true on a pick or rows entry uses the inferred sheet, stat grid, date
window, disclosure, and table rules. fill: true also paints own properties the
children don't name. With no layout and no as, structure: true expands an object
or an array of objects the same way an undescribed value expands.
{ key: 'factors', layout: 'rows', order: ['exploitation', 'exposure'], cells: [
{ key: 'value', as: 'pill', placeholder: { text: 'Unknown', tone: 'neutral' } },
{ key: 'gapKind', valueFrom: 'gap.kind', as: 'pill', only: 'string' },
{ key: 'evidenceRefs', as: 'tags', only: 'array', stringsOnly: true },
] }Kinds
FieldRenderer is the only switch over kinds.
| Kind | Paints |
|---|---|
| lead / text | <p class="fk-lead"> / <p class="fk-text"> |
| bullets | <ul class="fk-bullets">; non-strings are JSON.stringify'd |
| signals / qa | .fk-signal-list / .fk-qa-list of li.fk-signal.fk-tone-* |
| pill | Pill, tone and label from the vocabulary |
| mono | <code class="fk-mono"> |
| flag | true → Yes (blue), false → No (neutral), anything else → mono |
| stat | StatValue |
| tags | .fk-tags of TagChip |
| empty | <p class="fk-empty"> with emptyLabel |
| json | <pre class="fk-json">, scrolled when it is taller than the row |
| markdown | The kit Markdown renderer: headings, lists, and fences |
| custom | The host node from RecordView's custom prop, by entry key |
Pill tone: the entry's tone, else the vocabulary tone, else hashTone of the
string, else neutral. A vocabulary toneWhen: { path, eq, tone, else } applies
only when path is present on the record.
Pill label: UPPER_ENUM values use the vocabulary label, else defaultEnumLabel
(NEEDS_REVIEW → Needs review). lower_enum values always use
defaultEnumLabel. Other strings use the vocabulary label, else the raw string.
Non-strings are painted with String(value).
Signals accept strings, objects with a string text, or arrays of either. Entry
options: split: 'catalog-line' (splits ID-PART: text), textFrom / idFrom /
tagFrom, fallbackId, idLabel: 'raw' | 'vocabulary', stringsOnly, and
idTones: [{ prefix, tone }]. A row's tone is the vocabulary tone for its id, else
a matching idTones prefix, else the entry's tone, else neutral. Lines are
never truncated.
Labels
A label is the map's labels override, else the entry's label, else
humanizeLabel(key): sentence case, with acronyms in their display form.
| Key | Label |
|---|---|
| invoiceVatAmount | Invoice VAT amount |
| ipv6Address | IPv6 address |
| phLevel | pH level |
| cpuIds | CPU IDs |
| sha256Hash | SHA256 hash |
| externalAPIUrl | External API URL |
| someXYZValue | Some XYZ value |
| ledBy | Led by |
The built-in dictionary (ACRONYM_GROUPS) has over 500 terms in 30 groups:
general, computing, networking, telecom, identity, security, data, marketing,
sales, customer, people, business, finance, retail, logistics, property,
operations, quality, health, clinical, science, energy, legal, education, media,
gaming, digital assets, engineering, transport, and public sector. Terms keep their
canonical casing (OAuth, PhD, kWh, YoY, OpEx). Plurals (APIs) and
trailing digits (Q3, MP4) are recognized too. In mixed-case keys, capitals the
author wrote are kept. Tokens that are also common words or units (it, us,
led, hr, ms, map, mom, zip, kw, db) are left out on purpose, and no
brand names are listed. A test enforces both rules.
Hosts tune the dictionary with map.acronyms:
acronyms: ['ttl', 'GraphQL', '!pr'] // add TTL, set a canonical form, turn off PREnum pill labels use the same dictionary: SLA_BREACH → SLA breach. Lower-case
tokens read the same way: in_scope → In scope, and entity-context →
Entity context unless the vocabulary gives that hyphenated token a label.
Value formats
Off by default. With formats: true, or a list such as formats: ['date', 'url'],
a text value whose whole string has one of these shapes is displayed accordingly:
| Format | Matches | Renders |
|---|---|---|
| date | 2026-03-04, 2026-03-04T14:05:00Z, 2026-03-04 14:05 | <time>: Mar 4, 2026, 2:05 PM |
| duration | ISO 8601 such as PT1H30M, P2DT3H | <time>: 1h 30m |
| url | http:// or https:// links | <a class="fk-link">, opens in a new tab |
| email | [email protected] | mailto: link |
Dates use locale (default en-US). Timestamps with an offset are shown in
timeZone (default UTC). Timestamps without an offset are shown as written. The
defaults are fixed so server and browser render the same text. A value that
matches an enabled format is inferred as text, even an all-caps one like PT4H.
Values with affix: 'quote' are never formatted, and other text is unchanged.
detectFormat(value, options) exposes the same check.
Banners
banners: [
{ when: { path: 'scenarioSupport', neq: 'SUPPORTED' }, tone: 'amber', title: 'Scenario is not supported',
body: { join: 'reasonCodes', fallback: 'The reason codes below say which input is missing.' },
tags: { from: 'scenarioSupport' } },
]tags is a literal list, { from: path }, or 'matched' (the value of the
condition leaf that matched, or a deep-scan tag). deepScan: { pattern, depth, tag,
tagIfContains } walks strings in the record case-insensitively. Pass
context={{ ... }} to RecordView to give conditions extra values (which win over
record keys of the same name); context values are never painted.
RecordView
<RecordView
data={record}
map={map}
descriptors={{ contentType, objectType, knowledgeType }}
context={{ scopeReason }}
custom={{ chart: ({ value }) => <Chart data={value} /> }}
renderHeader={({ title, status }) => <MyHeader title={title}>{status}</MyHeader>}
omit={['diagnostics']}
labels={{ transformers: 'Models', 'meta.owner': 'Owner' }}
sectionLabels={{ additional: 'Other data' }}
/>Leaving parts out and renaming
omit leaves out parts that would otherwise paint. A part the record does not have is
skipped, and unknown names are ignored. The list is combined with map.omit.
| Part | Removes |
|---|---|
| diagnostics (or diagnostic) | The diagnostics disclosure, additional fields included |
| additional | Leftover fields only; mapped diagnostic rows stay |
| banner | The evaluated banner (an explicit banner prop still paints) |
| status, identity | Header pills, header meta |
| content | Body rows and the empty-body note |
labels renames fields by key or dotted path. A path beats a bare key, so
{ team: 'Group', 'meta.team': 'Squad' } names a top-level team and a nested one
differently. Renames apply after the map, descriptors, and inference have chosen a
label, so they win over all three. They reach header meta, nested sheets, disclosures,
and table column headers. sectionLabels sets the Diagnostics and Additional fields
titles. The prop of each name wins over the map's.
descriptors is optional. Omitting it, or passing {}, paints from the FieldMap alone.
fieldMapFromDescriptors(record, descriptors, map?) returns that merged map.
A host passes the JSON it already has. The kit does not fetch a descriptor. Unknown keys
on the JSON are ignored. described: false or fields: [] drops that slot only.
| Slot | Wins |
|---|---|
| FieldMap entry | Kind, label, layout, and position |
| knowledgeType | Same path, after the map |
| contentType | After the knowledge type |
| objectType | After the content type, when contentType is absent or equals the content type id |
A field with no path is skipped. Paths are tried as written, then with one leading
data. or properties. removed, so a data bag still resolves data.title and
properties.snapshotSetId. A [] segment means each object in that array
(interfaces[].name). Child paths of an object or array belong to that value. A dotted
path on its own does not consume its parent, so sibling keys stay under additional fields.
| valueType | Paints |
|---|---|
| string | text. Last segment markdown is markdown |
| enum set | pill. NODE displays as Node. Tone from the map vocabulary, else hashTone |
| number / boolean / recordId | stat / flag / mono |
| date, datetime | text with the date formatter on that field only |
| array | Short strings are tags (enum tokens are pills). Longer strings are bullets. [] child paths paint a table, one column per child. An array of objects with no [] children expands like an undescribed array. A mixed array, an empty array, or a value past the size caps is json |
| object | With child paths, a sheet or disclosure of those children. { from, to } is a date window; an all-number bag is a stat grid. With no child paths, json |
| content | Markdown. A { content, preview } object uses content, then preview |
| anything else | inferRule for that value |
A content or knowledge type's discriminator path names the record's type, so it is
hidden instead of painted in the body. A host entry for that path still paints it.
The first field whose last segment is displayName, title, or name becomes the
header title. A [] path does not. When that value is the title, the body row is omitted.
Declared scalars share the property sheet with inferred leftovers, so the label and the
value sit on one line. Stacked declared rows stay full width.
refersTo paints the id as mono and does not load the target. An empty declared value
is omitted. When a knowledge type has fields, each enum whose value is one of those
strings is a header status pill and is not repeated in the body. Without a knowledge
type, markdown and kind are not special.
Keys no entry names stay visible. When the map and the descriptors declare no content row, those keys are the body. Otherwise they are additional fields.
When nothing was declared
Inference reads the key and the value. A host conventions entry still wins. The rules
are generic, so a later domain is another row in the table rather than a name in the kit:
Id,Hash,Sha,Digest,Version,Revisionand a string →monoStatus,Verdict,State,Band,Severity,Readiness,Kind,Type,Role,Modeand a string →pillCount,Total,Score,Weightand a number →statAt,Time,Date, orasOf/from/to, when the string is a date → a dateName,Title,Label,Text,Summary,Why,Explanation,Messagestay prose- Otherwise: booleans, numbers, and
ALL_CAPStokens as before. A string may also match a date, duration, link, email, UUID, hex hash, IP, CIDR, or a no-space code. Mappedtextdoes not, unless the map setsformats. - A short string array is tags. Enum tokens are pills. Longer strings are bullets.
An all-number object is a stat grid. { from, to } plus at most one number is one date
range. An array of flat objects is a table when every string cell is short, ordered id,
name, label, code, status, kind, type, verdict, then the rest. A sentence or a line
break in any cell paints blocks. An array whose values are only locators (a dotted path
such as data.cveIds, or a no-space code such as inventory:0) and that contains a
path starts closed, with its count and no summary pill. A small object is an open sheet. A nested or wide group is
a disclosure, open while it is small. Its summary pill is the first state-like value
(status, verdict, state, severity), else the first category (kind, type,
role, mode). For a list, the pill is shown only when every item has that value.
false and 0 stay, drawn quieter. JSON remains for mixed arrays, cycles, more than 80
keys or 50 items, as: 'json', and a convention that picks json.
Two shapes are recognized before those rules. A record whose only keys are a hyphenated
kind (a document name such as entity-overview) and one object is that object: kind
is not painted, including when a field, status key, or identity key names it, and the
object's own fields are inferred in its place. kind: "NODE", and any record with another
key beside those two, are unchanged. An array of objects that are only a dotted path
(path, or a key ending in Path) and value is rebuilt into that tree and then inferred
as any other object. A leading data. or properties. shared by every path is removed.
Paths that disagree, or any other array of objects, stay a table. An explicit as or
layout on a field still paints that field as written. A title path can still read kind.
Structure: the banner, then the header (.fk-header, .fk-title,
.fk-header-status, .fk-header-meta), then .fk-content, then
<details class="fk-diagnostics"> with the summary Diagnostics N. Inside the
diagnostics, mapped rows come first, followed by .fk-additional with the heading
Additional fields. Both titles follow sectionLabels. Each row is .fk-field[data-field-key]; derived and layout rows
also carry .fk-field-projected, and leftover rows carry .fk-field-unmapped.
recordMarkdown
import { recordMarkdown } from '@x12i/fieldkit';
recordMarkdown(record, map, { descriptors, context, omit, labels, sectionLabels });
recordMarkdown('# Title\n\n## Summary\nHello\n');data is the same union RecordView takes. A markdown string is returned as it
was given. A JSON string is parsed, then written with map. map is required
for JSON and ignored for a markdown document.
omit, labels, and sectionLabels behave as they do on RecordView, so the text
leaves out and names the same parts the card does. They are not applied to a
markdown document.
The string is the same resolution RecordView paints. When descriptors is passed,
both call fieldMapFromDescriptors(data, descriptors, map) and then
resolveRecord with the same context. Context is visible to banner conditions
only. Defaults stay locale: 'en-US', timeZone: 'UTC', and emptyLabel: 'Not provided'.
The text walks the resolved record in paint order: banner, title, status, identity,
content, diagnostics, additional. A key the resolver drops is absent. A key it keeps
appears, in that order, with the label it chose. Tones are not written. A pill's text
is the label a person reads. false and 0 are written; muted paint is not a word.
| Part | Text |
|---|---|
| Banner | **{title}**, then the body, then a - list of tags |
| Title | # {title} |
| Status | One paragraph, labels joined by · |
| Identity, content | ## {label} and the body |
| Empty content | emptyBodyNote, when the card would show it |
| Diagnostics | ## Diagnostics, then each field as ### {label} |
| Additional fields | ## Additional fields, then each field as ### {label} |
| Field note | The next paragraph |
| Disclosure | Heading {label} · {count} · {preview}. The body is always included |
| List, flatten, pick | Child fields one level deeper. Under a #### heading, a - list of **{label}** {value} |
| Rows | Without columns, a - list. Row label, then **{label}** {value}. With columns, a table: header labels, then one row of cell text. A \| in a cell is a literal pipe |
| Blocks | A numbered list. Stacked children join one item with ·. At level 2, a block with nested structure or prose is a ### {label} {n} section with its fields under it |
| Date window | {from} – {to} |
| Kind | Body |
|---|---|
| lead, text | One paragraph. prefix, affix: 'quote', and detectFormat match the card |
| empty | The empty label |
| pill, flag, stat | Yes, No, the number, or the enum label |
| mono | `value` |
| bullets, tags | A - list. A non-string bullet is one JSON line. A tag is the chip text |
| signals, qa | A - list. An id is **{idText}** {text} |
| markdown | The field's markdown, spliced in. { content, preview } uses content, then preview |
| json, custom | A json fence. custom has no host node in text |
Blank line between fields. \n line endings, one trailing newline. A prose value is
one line; line breaks become spaces. A line that would be a heading, a list item, a
table row, or a fence is prefixed with \, which parseMarkdown drops. A fence closes on a line
that is exactly ``` at column 0.
recordMarkdown(
{ summary: 'No display name.', count: 2 },
{ fields: [
{ key: 'summary', as: 'lead' },
{ key: 'count', as: 'stat', bucket: 'diagnostic' },
] },
);## Summary
No display name.
## Diagnostics
### Count
2Other exports
version: the published package version, for a debug panel or a stale-bundle check.- Primitives:
Pill,TagChip,StatValue,Mono. MarkdownandparseMarkdown: fences (no inner parsing; a fence closes on```at column 0),#–####headings,-/*and numbered lists, tables (|header,| --- |separator, body rows; a lone|line stays a paragraph), paragraphs,**bold**, and`code`. A leading\before a structural line stays text.recordMarkdown: the resolved record as that same markdown. A markdown string is returned unchanged.- Helpers:
TONES,hashTone,inferRule,isEnumToken,isEmptyValue,humanizeKey,humanizeLabel,defaultEnumLabel,fieldLabel,ACRONYM_GROUPS,BUILTIN_ACRONYMS, andBUILTIN_STORAGE_KEYS. fieldMapFromDescriptorsand the descriptor typesDescriptorInput,MemorixFieldDescriptor,MemorixContentDescriptor, andMemorixObjectDescriptor.- Pure resolution:
recordMarkdown,resolveRecord,resolveList,evaluateBanners,evaluateCondition,buildSignalRows,splitCatalogLine,deriveValue,pillTone,pillLabel,detectFormat, andVALUE_FORMATS. stylesheet(string) and@x12i/fieldkit/styles.css. Theme by overriding the--fk-*custom properties.
Narrow containers
.fk-record is an inline-size container named fk. The width is the record's
content box, not the viewport, so no prop is needed.
Under 30rem (a sidebar or drawer), every column table paints one labeled line per
cell, the property sheet narrows its label column, and compact row labels sit above
their values. A table with more columns stacks sooner, at about 11rem per column:
4 columns under 44rem, 5 under 55rem, 6 under 66rem, 7 under 77rem, and 8
or more under 88rem. Two- and three-column tables stack only under 30rem. A
wider record keeps the column table.
Each table cell carries its column label in data-label, and the table carries its
column count in data-cols (capped at 12; the grid still uses the real count). The
cell text stays the value. Inside a cell, mono text and links wrap at hyphens and
otherwise stay whole. A pill stays on one line and wraps with the other chips.
Migrating from 1.x
2.0 replaces the function-based API. fieldRules, contentKeys, diagnosticKeys,
toneMap, classifySignal, mapItem, banner(), FieldKitProvider, and the
group and table kinds are gone. Express them as fields, bucket,
vocabulary, signal entry options, banners, CSS custom properties, and layouts.
markdown is a kind again, used when a descriptor field asks for it.
MIT
