@karmicsoft/lc-schema
v0.1.3
Published
Schema → neutral IR → widget definitions (schema compiler) — a LightCode brick.
Maintainers
Readme
@karmicsoft/lc-schema
Schema → neutral IR → widget definitions — a small schema compiler and a
LightCode brick. MIT — © 2026 KarmicSoft (see LICENSE).
Why (the SSOT strategy)
Keep one canonical schema and generate the rest, instead of hand-maintaining
several descriptions of the same model (which drift). lc-schema is the compiler:
CANONICAL SOURCE ─(reader)→ neutral IR ─(consumers/emitters)→ { lc widgets · JSON Schema · config.yml · … }
(your choice) (LightCode)The IR decouples the choice of canonical source: switching Zod ↔ config.yml later changes only the reader; consumers (the editor) are unaffected — reversible, no lock-in.
Install
npm install @karmicsoft/lc-schemaAPI
import { fromSveltiaConfig, fromZod, widgets, collections } from '@karmicsoft/lc-schema';
// from a Sveltia/Decap config.yml:
const ir = fromSveltiaConfig(readFileSync('public/admin/config.yml', 'utf8'));
// …or from your runtime Zod schemas (Astro content collections):
import { collections as astro } from './src/content.config.ts';
const ir2 = fromZod(astro); // { persons: {schema}, events: {schema} } or { persons: z.object(...) }
collections(ir); // ["persons", "events", ...]
widgets(ir, 'persons'); // flat widget list the lc-editor rendersfromZod is version-tolerant (Zod 3 & 4). Astro's reference('periods')
doesn't expose its target collection at runtime, so mark relations with
.describe('relation:periods') (a z.array of that is a multiple relation);
.describe('markdown' | 'image' | 'text') pick those widgets. A schema that is a
function (({ image }) => z.object(...)) must be resolved before passing it in.
⚠️ Astro integrators — mark relations inline, never through a wrapper. A generic helper around
reference()(e.g.const rel = (c) => reference(c).describe(...)) makes Astro's content types circular and type inference collapses — one integrator saw 231 errors from that alone. The supported pattern is inline, at each use site:periods: reference('periods').describe('relation:periods'),It costs one repetition per field and keeps inference intact.
A collection whose root is wrapped in z.preprocess(fn, z.object(...)) / effects /
.default() is unwrapped to the object before its fields are read. A root that
is not an object (e.g. a bare z.string()) throws a clear error instead of
compiling to zero fields silently.
Display labels & i18n. Labels resolve by precedence:
opts.labels → a label: directive → the prettified field name.
const ir = fromZod(astro, {
labels: { periods: { startDay: 'Jour de début' } }, // per active locale
});opts.labelsis keyed{ collection: { path: 'Label' } }— load it per locale so translations live outside the schema (one schema, N locales).Nested fields are dotted paths on the same flat map — you address the structure, you don't mirror it:
labels: { periods: { title: 'Titre', // top-level field daterange: 'Période', // the container keeps its own key 'daterange.startDay': 'Début', // a field inside that object 'addresses.role': 'Rôle', // objectlist child — NO index 'tags.value': 'Étiquette', // the item of a scalar list } }An objectlist child is labelled once and applies to every item (there is no
addresses.0.role). Anything unlisted falls back to the auto-label.fromSveltiaConfig(config, { labels })takes the same map — there it is an i18n overlay that wins over the config's ownlabel:, so you translate without forkingconfig.yml.Or bake a default label into the schema alongside a widget:
.describe('label:Époque | text')(thelabel:runs up to the next|).With neither, the field name is prettified:
startDay→ "Start Day",aiText→ "Ai Text" (camelCase and-/_become words).
Neutral IR shape
{ irVersion, source, collections: [
{ name, label, folder, extension, identifier, fields: [ Field ] }
]}
Field = { name, label, widget, required, hint?, default?,
options? // select: [{label, value}]
collection?, multiple?, valueField?, displayField? // relation
item? // list of scalars
fields? // object / objectlist
}Supported widgets: string · text · number · boolean · select · relation · list ·
objectlist · object · markdown · image · file · hidden.
SSOT — canonical source, your call (reversible)
- v0.1 reader: Sveltia/Decap
config.yml(already in your repo). - Recommended canonical: your Zod
content.config.ts— it is your build authority and the richest (reference(), refinements,nullSafe,dateStr);config.ymlis a subset. With a Zod reader (roadmap),config.ymlbecomes generated and the duplication disappears. - If you keep
config.ymlcanonical: add a CI check that it stays consistent with the Zod (which carries semanticsconfig.ymlcan't).
Either way, consumers read the IR, so the decision is reversible.
