Seed Protocol
@seedprotocol/mapping

Overview

What @seedprotocol/mapping does, how to install it, its two entry points and the types a mapping is made of.

@seedprotocol/mapping connects fields of outside content (RSS or Atom entries, Markdown files with frontmatter) to the properties of a Seed model. You parse the content into sources, build a list of edges from source to property (by hand, with autoMap, or with the FieldMapper UI), then apply the edges to each entry to get a plain object of property values that you pass to Item.create. The package never fetches anything, never touches the SDK's database and doesn't depend on any other Seed package.

import { rssXmlToSources, autoMap, applyMappingAsync } from '@seedprotocol/mapping'
import { FieldMapper } from '@seedprotocol/mapping/react'

For a walkthrough that imports a real feed into a Post model, see Import an RSS feed into your models.

Install

npm install @seedprotocol/mapping

The only runtime dependency is rss-parser. react and react-dom (18 or 19) are optional peer dependencies: install them only if you import @seedprotocol/mapping/react. The main entry has no React import, so it runs in Node scripts and servers without them.

Entry points

ImportWhat it hasNeeds React
@seedprotocol/mappingParsers, autoMap, applyMapping/applyMappingAsync, coerceValue, lookup and edge helpers, all the types. See HelpersNo
@seedprotocol/mapping/reactThe FieldMapper component, the useFieldMapper hook, theme and slot helpers. See FieldMapper UIYes

Import FieldMapper from /react, not from the main entry. There is no /node entry; the main entry is the Node one.

How the pieces fit

  1. Sources. A parser turns one entry (one RSS item, one Markdown file) into a list of SourceNodes, one per field, each with a stable ID such as rss-title or fm-summary.
  2. Targets. You describe the model you're importing into as a list of TargetPropertys. Build them from your schema file so they match it.
  3. Edges. A mapping is a list of FieldMapping edges, each from a source ID to a property name. autoMap guesses a first set from field names; FieldMapper lets a user edit them. A mapping depends on the shape of the feed, not on one entry, so you build it from a sample entry and reuse it for every entry.
  4. Apply. applyMapping (synchronous) or applyMappingAsync turns one entry's sources into a PropertyBag, converting each value to its target's type. Some edges need work only your app can do (fetch a page, download an image); applyMappingAsync hands those to a callback you supply.
  5. Save. Wrap the edges in a MappingDocument and store it as JSON so the next import reuses it.
import { Item } from '@seedprotocol/sdk'
import {
  rssXmlToSources,
  autoMap,
  applyMappingAsync,
  type MappingDocument,
  type TargetProperty,
} from '@seedprotocol/mapping'

const targets: TargetProperty[] = [
  { name: 'title', dataType: 'Text', required: true },
  { name: 'body', dataType: 'Html' },
  { name: 'publishedAt', dataType: 'Date' },
]

const xml = await (await fetch('https://blog.example.com/rss.xml')).text()
const { items } = await rssXmlToSources(xml)

const doc: MappingDocument = {
  version: 1,
  sourceKind: 'rss',
  mappings: [...autoMap(items[0] ?? [], targets), { sourceId: 'rss-isoDate', propertyName: 'publishedAt' }],
}

for (const sources of items) {
  const { properties, errors } = await applyMappingAsync(sources, doc.mappings, targets)
  if (errors.length > 0) console.warn(errors)
  // A Date target comes back as epoch milliseconds; the SDK stores ISO strings
  const { publishedAt, ...rest } = properties
  const values = {
    modelName: 'Post',
    ...rest,
    ...(typeof publishedAt === 'number' ? { publishedAt: new Date(publishedAt).toISOString() } : {}),
  }
  await Item.create(values)
}

Core types

All types are exported from the main entry.

SourceNode

One mappable field of one entry.

type SourceNode = {
  id: string
  label: string
  subtitle?: string
  kind: SourceKind
  value: string
  meta?: Record<string, unknown>
}

type SourceKind = 'frontmatter' | 'section' | 'full' | 'rssField' | 'xmlPath' | 'resolved'
FieldDescription
idStable ID, the same for the same field in every entry: rss-title, rss-enclosure-0, fm-title, section-full, section-0. Edges point at it
labelDisplay name, usually the field name (title, content:encoded, enclosure[0])
subtitleOptional second display line in the UI. Not saved in a mapping
kindWhere it came from. rssField for RSS/Atom, frontmatter, full and section for Markdown, resolved for the extract/file candidates buildResolvedSourceNodes adds. The type includes xmlPath, but nothing in the package produces it
valueThe field's text. Apply copies value only, so an empty field stays empty rather than taking the label
metaExtra data. Media sources carry url, contentType, length and class (see UrlMediaClass); Markdown frontmatter carries valueType, sections carry level

You can build SourceNodes yourself for any other format; the apply functions only look at id, value, label and meta.

TargetProperty

One property of the model you're importing into.

type TargetProperty = {
  name: string
  dataType: string
  ref?: string
  refValueType?: string
  required?: boolean
}
FieldDescription
nameThe property name, as in your schema
dataTypeThe property's type: Text, Html, Number, Boolean, Date, Json, Image, File, Relation or List. Decides how values are converted
refFor Relation and List of Relation, the related model's name. Your schema file calls this model
refValueTypeFor List, the element type, e.g. Relation or Text
requiredA hint for FieldMapper, which flags required properties nothing maps to. Apply ignores it

A target is a relation target when dataType is Relation, or List with refValueType: 'Relation' or a ref. Relation targets never get a copied string; they need a lookup edge.

FieldMapping (an edge)

type FieldMapping = {
  sourceId?: string
  propertyName: string
  resolve?: ResolveJob
  lookup?: { entries: LookupEntry[] }
  split?: { separators: LookupSeparator[] }
  derive?: DeriveSpec
  assemble?: AssembleSpec
}

type ResolveJob = 'extract' | 'file' | 'lookup' | 'derive' | 'assemble'
type LookupSeparator = 'and' | '&' | ','

Rules for a list of edges:

  • Each property takes at most one edge. If two edges name the same property, both apply functions process them in order and the last one wins. FieldMapper in its default mode can't produce that state.
  • One source can feed several properties, e.g. rss-link into both importUrl and canonicalUrl.
  • sourceId is optional only for a derive edge that takes its value from another property.

Without resolve, an edge copies the source's text and converts it to the target type. resolve names any other transform:

resolveMeaningWho applies it
(none)Copy and convertBoth apply functions
'lookup'Translate the source's text (an author's name) into item IDs using the edge's lookup.entries. With split, a value that has no entry of its own is split on the separators and each part looked up (see Relation lookups)Both apply functions, from the stored entries
'extract'The source is a URL; fetch the page and extract HTMLapplyMappingAsync, through your resolve callback. applyMapping skips it
'file'The source is a media URL; download it and store it as a fileapplyMappingAsync, through your resolve callback. applyMapping skips it
'derive'Compute this property from another property (derive.from), e.g. a slug from titleYour app. Both apply functions skip it
'assemble'Build stored HTML from named blocks (assemble.blocks)Your app. Both apply functions skip it

derive and assemble are stored on the edge so a mapping can record them, but the package doesn't interpret derive.from or the block names.

LookupEntry

type LookupEntry = { value: string; refs: string[] }

One row of a lookup table: the source text (value, e.g. "Jane Doe") and the IDs to store (refs, typically seedLocalIds or seedUids), in the order the value names them, lead first. A joint byline such as "Sam Ortiz & Mira Chen" can have one entry with two refs. A List target gets every ref, a Relation target the first. An entry saved by 0.7.12 or earlier as { value, ref } still applies, read as refs: [ref].

Entries match on the lookup key (lookupKey): the text in Unicode NFC, trimmed, with runs of whitespace collapsed to one space, compared without case, so "MIRA CHEN" finds the "Mira Chen" entry. The package never checks that the refs exist.

DeriveSpec and AssembleSpec

type DeriveSpec = { from: string; fallback?: boolean }
type AssembleSpec = { blocks: string[] }

DeriveSpec.from names the property to derive from. fallback: true means derive even when a mapped source is empty; a copy edge can carry a derive as that fallback. AssembleSpec.blocks is your own list of block names.

MappingDocument

The JSON you save.

type MappingDocument = {
  version: 1
  sourceKind: 'markdown' | 'rss' | 'xml'
  mappings: FieldMapping[]
}

Lookup tables live on their edges (lookup.entries), so a saved document keeps them. TargetProperty.required and SourceNode.subtitle aren't part of it. There is no load or validate function; see Saving and loading a mapping.

PropertyBag and results

type PropertyBag = Record<string, unknown>

type ApplyMappingAsyncResult = {
  properties: PropertyBag
  errors: ApplyMappingError[]
}

type ApplyMappingError = {
  sourceId?: string
  propertyName: string
  resolve?: ResolveJob
  message: string
}

A PropertyBag maps property names to converted values. Properties the entry had no source for are absent, not undefined.

ResolveContext and ResolveCallback

type ResolveCallback = (ctx: ResolveContext) => Promise<unknown>

type ResolveContext = {
  job: ResolveJob
  source: SourceNode
  target: TargetProperty
  url?: string
  contentType?: string
  class?: UrlMediaClass
  rawValue?: string
}

type UrlMediaClass = 'html' | 'image' | 'audio' | 'video' | 'unknown'

The callback applyMappingAsync calls for extract and file edges. See Resolve callbacks.

On this page