g

gairm-import

@preview

Parses and validates JSON against JSON Schema and returns a normalised dict; built-in support for JSON Resumes.

v0.9.0
MIT

Package Information

Last Updated
Minimum Typst Version
0.15.0
Categories
utility

1. Get the package

Download the package using the TPIX CLI:

tpix get @preview/gairm-import:0.9.0

2. Import in your Typst file

Add this to your .typ file:

#import "@preview/gairm-import:0.9.0": *

Version History

0.9.00.15.0
f7ac1fcfc8e9...
0.8.10.15.0
1576c02ffe64...

gairm-import

gairm-import on Typst Universe Latest GitHub release version of gairm-import GitHub Actions build workflow status on the gairm-import main branch MIT license badge linking to the gairm-import LICENSE file Number of GitHub stargazers for gairm-import

JSON Schema → Typst dict coercer.
Validate a JSON document against a JSON Schema (draft 7 subset) and return a normalised Typst dict ready for downstream rendering. Ships with the JSON Resume schema and convenience entry points as the canonical bundled example.

"gairm" is Irish for vocation. The package was originally a JSON Resume loader.

Contents

Highlights

  • Strict validation with path-qualified error reports and "did you mean …?" hints.
  • One-call parse that validates and coerces in a single step, or split via validate and coerce.
  • Two flavours of the canonical JSON Resume schema: a faithful 1:1 derivation and a renderer-friendly strict variant.
  • Bring your own JSON-Schema-shaped document via the schema: keyword — the engine is JSON-Schema-driven, not CV-specific.
  • Functional, lens-based editing for extension schemas without re-spelling the canonical shape.
  • A JSON Schema (draft 7 subset) → Typst schema translator for callers with an existing .json schema.

Requirements

  • Typst 0.15.0 or later.

Quick start

#import "@preview/gairm-import:0.9.0": parse

#let resume = parse(path("resume.json"))
// hand `resume` to any compatible Typst CV template

A minimal resume.json:

{
  "basics": {
    "name": "Seán Ó Murchú",
    "label": "Senior Software Engineer",
    "email": "sean@example.com",
    "summary": "Backend engineer with eight years of experience."
  },
  "work": [
    {
      "name": "Acme Corp",
      "position": "Senior Software Engineer",
      "startDate": "2022-01",
      "highlights": ["Led the event-sourcing platform migration."]
    }
  ]
}

The canonical schema covers thirteen sections: basics, work, volunteer,
education, awards, certificates, publications, skills, languages,
interests, references, projects, meta. The $schema top-level
metadata field is also accepted. See
jsonresume.org/schema for every field.

Bring your own schema

gairm-import is JSON-Schema-driven, not CV-specific. Two ways to use a
non-CV shape.

Translate an existing .json schema with schema-from-json-schema
(JSON Schema draft 7 subset — supported keywords listed under
Starting from a JSON Schema document):

#import "@preview/gairm-import:0.9.0": parse, schema-from-json-schema

#let book-schema = schema-from-json-schema(json("book-schema.json"))
#let book = parse(path("book.json"), schema: book-schema)

Or build the schema directly in Typst with the public combinators:

#import "@preview/gairm-import:0.9.0": (
  parse, object, array-of, str-type, number-type,
)

#let book-schema = object((
  title:  str-type,
  author: str-type,
  year:   number-type,
  tags:   array-of(str-type),
))

#let book = parse(path("book.json"), schema: book-schema)

Both go through the same validate / coerce / error-reporting
machinery — the JSON Resume schemas are just the bundled default. See
Schemas and composition for the full
schema-building API (lenses for targeted edits, the supported /
out-of-scope JSON Schema keywords, and more).

API at a glance

The five names a first-time reader will meet. Lens, introspection, and
schema-building helpers are introduced later in Schemas and composition.

Export Purpose
parse(data, schema: ...) Validate and coerce in one call; aborts compilation with a combined report on errors.
validate(data, schema: ...) Return a list of (path, message) records — empty list means valid.
coerce(data, schema: ...) Coerce a (validated) document into the typed model.
resume-schema Default schema — faithful 1:1 derivation of the canonical JSON Resume document.
resume-schema-strict Renderer-friendly overlay — free-text fields typed as Typst content, iso8601 $ref fields validated as dates.
schema-from-json-schema(doc) Translate an existing JSON Schema (draft 7 subset) document into a Typst schema. Pair with parse(..., schema: ...).
object, array-of, kind primitives Build a Typst schema directly. See Schemas and composition.

Usage

Loading the document

parse is the one-call entry point. The recommended form is
parse(path("resume.json")) — the
path value resolves
against your own .typ (not the @preview cache), so you can use the
natural relative path:

#import "@preview/gairm-import:0.9.0": parse

#let resume = parse(path("resume.json"))

A parsed dict, a json("…") wrap, or a Typst-root-relative "/…" string
are also accepted — useful on older callers or when you've already loaded
the document yourself:

// json() resolves the path against your .typ; parse takes the dict.
#let resume = parse(json("resume.json"))

// Typst-root-relative path string, resolved by parse itself.
#let resume = parse("/resume.json")

The returned model

The returned dict is a 1:1 mirror of the canonical schema — every kind comes
from the upstream JSON Schema document. Format-annotated fields are gated by
a regex (see Format validation); everything else passes
through as JSON-native types. For example:

resume.basics.name            str
resume.basics.summary         str
resume.basics.email           str (gated as email)
resume.work.at(0).position    str
resume.work.at(0).highlights  array of str
resume.skills.at(0).keywords  array of str

For renderer-friendly opinions (free-text fields wrapped as Typst content,
iso8601 $ref fields validated as dates), import resume-schema-strict
instead and pass it via the schema: keyword — see
Two schemas.

Rendering with a template

Pass the model into any compatible renderer — e.g. altacv:

#import "@preview/altacv:1.1.1": alta
#import "@preview/gairm-import:0.9.0": parse

#alta(parse(path("resume.json")))

If the renderer expects fields outside the canonical JSON Resume shape, build
an extension schema with the public combinators and pass it as schema:
see Building an extension schema.

Errors

validate returns a list of (path, message) records — empty list means
the input is valid. parse validates first and aborts compilation with a
combined report on the first invocation that finds issues, so every problem
in the document surfaces in one error:

error: assertion failed: gairm-import: found 3 problems in the input:
  - basics.email: expected string, got integer.
  - work[0].positon: unknown key "positon". Did you mean "position"?
  - meta.foo: unknown key "foo". Valid keys: canonical, version, lastModified.

When a typo is within edit distance 2 of a valid key, the message surfaces
a short "Did you mean …?" hint; otherwise it falls back to the full
valid-keys list shown for meta.foo.

Handling validation errors yourself

Each error is a record
(path: ("basics", "email"), message: "expected string, got integer.").
To present errors yourself instead of letting parse abort compilation,
run the two steps separately:

#import "@preview/gairm-import:0.9.0": validate, coerce

#let raw = json("resume.json")
#let errors = validate(raw)
#if errors.len() > 0 {
  [Resume has #errors.len() issue(s).]
} else {
  let model = coerce(raw)
  // render model …
}

Null handling

JSON null is treated as if the key were absent — no validation error,
dropped from the coerced model. Null elements inside arrays are dropped the
same way. This matches the convention used by most JSON Resume emitters,
where "summary": null is semantically equivalent to omitting the key.
Unknown keys are still flagged even when their value is null, so typos do
not slip through silently.

Root null is rejected: if the entire input document is null, validate,
coerce, and parse panic with
gairm-import: input must be a dict, got null. The null-as-absent policy
applies to leaf positions inside a document, not to the document itself.

Schemas and composition

Two schemas

The package exports two values of the canonical schema:

  • resume-schema — a faithful 1:1 translation of the vendored upstream
    JSON Schema document. Every kind comes from the source; nothing is
    rewritten. This is the default when you call parse(data) /
    validate(data) / coerce(data).
  • resume-schema-strict — adds three layered opinions on top via the
    lens API:
    • free-text fields (basics.summary, work[].summary,
      work[].highlights[], etc.) are typed as Typst content so they
      splice directly into markup
    • iso8601 $ref fields (startDate, endDate, …) are validated as
      ISO-8601 dates (the upstream document doesn't carry a format
      annotation on them, just a regex inside a definition)
    • upstream's additionalProperties: true markers — declared on every
      section's items, so the faithful default lets undeclared extras pass
      through — are stripped recursively, restoring the "unknown keys are
      rejected" promise (typed extras via additionalProperties: <schema>
      are kept)

Pass schema: resume-schema-strict to opt in:

#import "@preview/gairm-import:0.9.0": parse, resume-schema-strict

#let resume = parse(path("resume.json"), schema: resume-schema-strict)

The faithful default is the source-of-truth view; the strict variant is a
renderer-ergonomics overlay. If you want a different mix, build your own by
lensing over resume-schema — see
Targeted edits with lenses.

Format validation

Fields the canonical schema annotates with format: "uri", format: "email",
or format: "date" are gated by a regex during validate / parse. The
patterns are deliberately permissive — they reject obvious malformations
without claiming full RFC compliance — and each emits a path-qualified
message with a canonical example:

basics.email:           expected an email (e.g. "name@example.com").
basics.url:             expected a URI (e.g. "https://example.com").
certificates[0].date:   expected an ISO-8601 date (e.g. "2024-01-15").

format: "date-time" maps to the separate datetime-string kind:
date-string accepts YYYY / YYYY-MM / YYYY-MM-DD, while
datetime-string requires the full YYYY-MM-DDTHH:MM:SS shape (optional
fractional part and Z / ±HH:MM offset). The canonical document carries
no date-time annotations, so that kind only fires via
schema-from-json-schema or a lens override.

Most date fields in JSON Resume (work[].startDate, awards[].date,
meta.lastModified, …) use $ref: "#/definitions/iso8601" rather than
format: "date". The translator can't pick formats up from a $ref alone,
so those fields stay as plain str in resume-schema. Switch to
resume-schema-strict to validate them as dates, or build your own override
list with lens-put(lens(path), schema, date-string).

Coercion is pass-through: format-checked values flow through to the model as
plain strings, so renderers receive model.basics.email == "name@example.com"
unchanged.

For ad-hoc constraints outside the four built-in formats, build a
pattern-string(re, expected: …) and target it via a lens or splice it
into an extension schema. JSON Schema's pattern keyword on a plain string
maps to this kind too — see
Starting from a JSON Schema document
for the precedence rule when both format and pattern are present:

#import "@preview/gairm-import:0.9.0": (
  resume-schema, lens, lens-put, pattern-string,
)

// Gate basics.location.countryCode as an ISO 3166-1 alpha-2 code.
#let with-country-code = lens-put(
  lens(("basics", "location", "countryCode")),
  resume-schema,
  pattern-string(
    "^[A-Z]{2}$",
    expected: "an ISO 3166-1 alpha-2 code (e.g. \"US\")",
  ),
)

Typst's regex match finds a match anywhere in the string, so anchor the
pattern yourself if you need a full-string match — ^…$ is the common case.

Building an extension schema

parse is strict against declared fields in the canonical schema: keys that
aren't declared and aren't covered by an upstream additionalProperties
clause are rejected. (Upstream JSON Resume sets additionalProperties: true
on every section's items, so extras in those positions pass through —
Two schemas covers the strict variant that strips them.)

Renderers that expect their own top-level fields in the resume document
(e.g. alta-typst's focusAreas) can build a JSON-Resume+ schema with the
public combinators and pass it to parse / validate / coerce via the
schema: keyword:

#import "@preview/gairm-import:0.9.0": (
  resume-schema, parse, object, array-of, content-type,
)

// Splice the canonical shape and add a renderer-specific field.
#let altacv-schema = object((
  ..resume-schema.shape,
  focusAreas: array-of(content-type),
))

#let model = parse(path("resume.json"), schema: altacv-schema)

resume-schema.shape is a plain dict, so ..resume-schema.shape is the
only operator you need to extend it. Per-section combinators (work-item,
volunteer-item, …) are intentionally not exposed yet — splice the
canonical top-level fields whole and add your own siblings.

Targeted edits with lenses

Splicing ..resume-schema.shape works for top-level additions but is
awkward when the field you want to touch is three or four levels deep
(work items' highlights element schema, basics.email, …). For those
cases, lenses target a path inside the schema and return a new schema with
the targeted node replaced or transformed:

#import "@preview/gairm-import:0.9.0": (
  resume-schema, lens, lens-put, lens-over, add-field,
  set-required, unset-required,
  str-type, content-type, number-type, object,
)

// Widen basics.summary from content (rich) to str (plain) — useful if
// you want the summary rendered as plain text instead of formatted:
#let plain-summary = lens-put(
  lens(("basics", "summary")), resume-schema, str-type,
)

// Add a numeric `rating` to every language entry — touches
// resume-schema.shape.languages.elem.shape without re-spelling the wrapper:
#let with-rating = add-field(
  resume-schema, lens(("languages", "items")), "rating", number-type,
)

// Transform an existing node with a function:
#let with-extra-meta = lens-over(
  lens(("meta",)),
  resume-schema,
  meta => object((..meta.shape, source: str-type)),
)

// Make basics.name and basics.email required for your template
// (canonical schema declares no required keys):
#let strict-basics = set-required(
  resume-schema, lens(("basics",)), ("name", "email"),
)

// Relax email back without re-spelling the rest of the required list:
#let mixed-basics = unset-required(
  strict-basics, lens(("basics",)), ("email",),
)

Path segments match JSON Schema keyword names: object keys as strings, the
literal "items" to enter an array's element schema, and the literal
"additionalProperties" to enter an object's additional (the
additionalProperties schema; only valid when additional is a schema dict,
not true). Composition (lens-then(a, b)) concatenates paths, so
lens-then(lens(("work",)), lens(("items", "highlights"))) is the same
lens as lens(("work", "items", "highlights")). The empty path lens(())
is the identity lens.

Function Shape Behaviour
lens(path) path → lens Construct a lens from a path tuple
lens-get(l, schema) lens, schema → sub-schema Read the targeted node
lens-put(l, schema, value) lens, schema, sub → schema Replace the targeted node
lens-over(l, schema, fn) lens, schema, (sub → sub) → schema Apply a function to the targeted node
lens-then(a, b) lens, lens → lens Compose two lenses (path concatenation)
add-field(schema, parent, key, sub) … → schema Add a key to the object at parent
remove-field(schema, parent, key) … → schema Remove a key from the object at parent
set-required(schema, parent, keys) … → schema Replace the object's required-keys list at parent
unset-required(schema, parent, keys) … → schema Drop specific entries from the object's required-keys list at parent

Operations are functional — each helper above returns a NEW schema and
leaves the input untouched, so you can chain edits without disturbing the
canonical one. (Operations are top-level functions rather than methods
because Typst parses lens.put(…) as a type-method lookup, not a closure
call.)

Inspecting a schema

When an extension schema misbehaves, describe-schema, paths-of-kind,
and kind-at answer the three usual questions — what does this thing
look like?
, where do my date strings live?, what kind is at this
path?
— without repr(schema) or hand-walking .shape:

#import "@preview/gairm-import:0.9.0": (
  resume-schema-strict, describe-schema, paths-of-kind, kind-at,
)

// Tree view of every leaf, with array sections suffixed `[]`.
#describe-schema(resume-schema-strict)
// basics:
//   email    email-string
//   name     str
//   summary  content
//   …
// work[]:
//   highlights[]  content
//   startDate     date-string
//   …

// Every lens-compatible path whose terminal kind matches.
#paths-of-kind(resume-schema-strict, "date-string")
// → (("work", "items", "startDate"), …)

// Kind at a single path — thin wrapper over lens-get.
#kind-at(resume-schema-strict, ("basics", "summary"))  // "content"

Array segments in returned path tuples use "items" so they plug straight
into lens(path); the [] suffix in describe-schema's output is
human-friendly visual only. Keys sort alphabetically so diffs across schema
versions stay stable.

The real leverage comes from folding paths-of-kind together with
lens-put to bulk-edit every field of a kind in one pass — the list of
paths is derived from the schema, so new fields an upstream JSON Resume
bump introduces are covered automatically:

#import "@preview/gairm-import:0.9.0": (
  resume-schema, paths-of-kind, lens, lens-put, pattern-string,
)

// Tighten every uri-string field to a corporate-domain pattern,
// without enumerating the paths by hand.
#let corporate-uri = pattern-string(
  "^https://(corp|docs)\.example\.com/",
  expected: "a corporate URL",
)
#let corporate-schema = paths-of-kind(resume-schema, "uri-string").fold(
  resume-schema,
  (schema, path) => lens-put(lens(path), schema, corporate-uri),
)

JSON Pointer interop

Lens paths and validator error paths are (seg, seg, ...) tuples — natural
in Typst but they don't directly interoperate with external tooling that
speaks RFC 6901 JSON Pointer
(editor extensions for schema-aware completion, schema diff tools, JSON
Schema documentation generators, …). path-to-pointer / pointer-to-path
cross the boundary:

#import "@preview/gairm-import:0.9.0": path-to-pointer, pointer-to-path

#path-to-pointer(("basics", "email"))            // "/basics/email"
#path-to-pointer(("work", 0, "highlights", 1))   // "/work/0/highlights/1"
#path-to-pointer(("a/b",))                       // "/a~1b"     — `/` escapes as `~1`
#path-to-pointer(("~tilde",))                    // "/~0tilde"  — `~` escapes as `~0`

#pointer-to-path("/work/0/highlights/1")         // ("work", 0, "highlights", 1)
#pointer-to-path("")                             // ()          — whole document
#pointer-to-path("/")                            // ("",)       — empty-string key at root

Two addressing schemes share the same encoder. Validator error paths
(mixed str / non-negative int) address into data — the output is a
real RFC 6901 pointer any JSON-Pointer-aware tool can dereference against
the document. Lens and introspect paths (str-only, with "items" and
"additionalProperties") address into the schema — the output names a
schema location the way JSON Schema tooling does in $ref
(e.g. #/properties/foo/items), not a data position.

Malformed input panics rather than passing through silently: non-str/int
or negative segments at encode, invalid ~ escapes at decode.
pointer → path → pointer round-trips losslessly; path → pointer → path
is lossless except for str segments that look like array indices
(("0",) decodes back as (0,)) — which validator and lens paths never
emit in practice.

Starting from a JSON Schema document

schema-from-json-schema(parsed-schema) translates a JSON Schema (draft 7
subset) into a Typst schema dict. Use it when you already have an
authoritative .json schema and don't want to keep a parallel Typst copy
in sync:

#import "@preview/gairm-import:0.9.0": (
  schema-from-json-schema, coerce, object, array-of, content-type,
)

#let canonical = schema-from-json-schema(path("resume-schema.json"))
#let altacv-schema = object((
  ..canonical.shape,
  focusAreas: array-of(content-type),
))

#let model = coerce(json("resume.json"), schema: altacv-schema)

Supported keywords:

  • type: string / number / integer / array / object / boolean /
    nullinteger rejects numbers with a non-zero fractional part
    (1.0 passes, per draft-7 semantics); the matching Typst-side
    primitive is integer-type
  • format: uriuri-string, emailemail-string,
    datedate-string, date-timedatetime-string
  • patternpattern-string (on plain string schemas only; when both
    format and pattern are present on the same node, format wins and
    pattern is dropped — compose two gates yourself via a lens if you need
    both)
  • enumenum-of, constconst-of
  • properties, required, items
  • Internal $ref (#/definitions/… / #/$defs/…)
  • anyOfany-of (at least one member matches), oneOfone-of
    (exactly one member matches), notnot-of (value must not match) —
    the composition keyword must stand alone on its node (annotation-only
    siblings like title / description excepted); a sibling type,
    constraint, or $ref panics rather than being silently ignored
  • allOf — merged at translate time; every member must be an object
    schema (shapes union, required union; a duplicate key must carry an
    identical sub-schema; additionalProperties must agree across all
    members, with an undeclared member counting as closed). Non-object
    composition (e.g. string + extra constraints) panics
  • type: [X, "null"] nullable unions (under the engine's null-as-absent
    policy these translate to plain X)
  • String constraints: minLength, maxLength
  • Number constraints: minimum, maximum, exclusiveMinimum,
    exclusiveMaximum, multipleOf
  • Array constraints: minItems, maxItems, uniqueItems
  • additionalProperties: a schema, true, or falsefalse matches
    the strict default; true permits extras without validation; a schema
    validates every extra against it (also reachable via the
    map(value-schema) combinator)

Constraint keywords are baked onto the kind dict as kebab-case fields and
validated inline.

Out of scope. Each keyword below panics with a clear "unsupported"
message rather than silently dropping the constraint:

  • if / then / else
  • dependencies (and the dependentRequired / dependentSchemas variants)
  • Constraint keywords (minLength, minimum, minItems, …) combined with
    enum / const — membership already pins the exact values, so fold the
    constraint into the value list instead (type alongside enum / const
    remains accepted as redundant)
  • type: "object" with neither properties nor additionalProperties
    (fully open)
  • type: [...] unions with more than one non-null member
  • External $ref
  • String formats other than the four listed above
  • allOf with non-object members, and schema-bearing sibling keywords
    beside any composition keyword (move them into the members)

Contributing

See CONTRIBUTING.md. Releases are cut by
release-please from
conventional-commit titles on main.

License

MIT.