aem-to-sanity-schema
v2.9.0
Published
Generate Sanity object schemas (and TypeScript types) from AEM Granite UI component dialog definitions.
Downloads
1,789
Readme
aem-to-sanity-schema
Reads AEM Granite UI component dialog definitions and emits:
- Sanity object schemas (
defineType/defineFieldTypeScript files — one per AEM component) - Matching TypeScript types via official Sanity TypeGen (
sanity schema extract+sanity typegen generate) - A migration report (
migration-report.json) with per-component outcome and unmapped fields - An audit artifact pointing at concrete JSON examples of unmapped AEM resource types — makes extending the mapping table straightforward
Usage (programmatic):
import { migrateSchemas } from "aem-to-sanity-schema";Usage (CLI):
aem-to-sanity-schema # read config from env/.env
aem-to-sanity-schema --verbose # + log every AEM GET
aem-to-sanity-schema --continue-on-authFlags:
--verbose/-v(orAEM_VERBOSE=true) — elevate logger todebug; surfaces everyGET {url}+ Sling.N.jsondepth-fallback retries issued byaem-to-sanity-core's AEM fetcher.--continue-on-auth(orAEM_CONTINUE_ON_AUTH=true) — treat per-component 401/403 as per-path ACL skips and keep going, as long as at least one component succeeds. A circuit breaker still aborts onNconsecutive auth failures with zero successes.
Startup banner (always printed at info level) shows what the run is about to connect to: AEM env + base URL, auth kind (basic: username only; bearer: len=N, prefix=abcd…), paths / roots files, output dir, concurrency. A Sanity preflight block reports SANITY_PROJECT_ID / SANITY_DATASET / token presence — the schema stage never calls Sanity, it's a config sanity check for the downstream content ingest.
Reserved-name handling: resolveSanityTypeNames (exported from this package) maps each AEM component path to its final Sanity type name up front. Bases that collide with Sanity built-ins (image, file, slug, text, etc.) are prefixed with aem at emission time, and the same resolved name lands in schemas/*.ts, pageBuilder.of[], and content-type-registry.json — so the Studio never has to rename and ingested _type values match what the schema registers.
Type-naming strategy (MIGRATION_TYPE_NAMING). By default the type name comes from the JCR path segments after components/ (proxy/content/cardcontainer → proxyContentCardcontainer) — unique by construction, maximally stable. Set MIGRATION_TYPE_NAMING=title to name types from each component's jcr:title instead ("Card Container" → cardContainer, with the redundant trailing " component" stripped the same way Studio labels are). Title mode pre-fetches every component node so titles are known before names resolve (the main pass reuses the fetched nodes, so components aren't fetched twice). Missing/blank titles fall back to the path-derived name; a title collision keeps the first component (in aem-component-paths order) clean and suffixes later ones with their path-derived name (teaser + teaserProxyContentTeaser); reserved built-ins still get the aem prefix. Every fallback is logged at info level. Stability caveat: titles are editable in AEM, and the type name IS the ingested _type — pick the strategy once before the first import, and treat a later switch (or an AEM-side title rename) as a re-migration (--recreate-on-type-change or a fresh dataset).
Global type-name suffix (MIGRATION_TYPE_SUFFIX). Appends a suffix verbatim to every strategy-derived type name (proxyContentHero → proxyContentHeroBlock with Block) so generated types can match an existing customer schema's naming vocabulary. Works with either naming strategy; the suffix lands last on every derived candidate — base, aem-prefixed, and collision-disambiguated names alike (aemImageBlock, teaserProxyContentTeaserBlock) — with only the last-resort numeric tiebreaker after it. Reserved-name checks run against the suffixed name, so a component named image emits as imageBlock with no aem prefix needed. Explicit names from aem-component-names.json are exempt — they emit exactly as written. Letters/digits/underscore only, and casing is verbatim (block yields heroblock). Same set-once-before-first-import hazard as the strategy: changing the suffix renames every emitted type and orphans ingested _type values.
Suffix mode (MIGRATION_TYPE_SUFFIX_MODE). Controls what the suffix decorates. type (default) is the behavior above — the suffix is part of the type name itself. file applies it only to each generated file's basename and its export const identifier: accordionType.ts exports accordionType, but declares defineType({ name: "accordion" }) — the common Studio convention. In file mode the type name, registry sanityType, pageBuilder.of[], and ingested _type values all stay bare, so the knob is safe to add, change, or drop between runs (only file names move; the pruner removes copies at the old basenames, and the barrel/typegen pick up the new export identifiers automatically since export == basename by convention). It applies to every component file — explicit aem-component-names.json names included, since the override pins the type name while the file convention is purely on-disk; pin an entry's file to control its basename exactly (see per-component overrides below). Toolkit-owned files (page.ts, pageBuilder.ts, table types, contentFragment) keep bare names in both modes.
Per-component overrides (aem-component-names.json). An optional tenant file pins the emitted type name, Studio title, output folder, file basename, and/or Studio icon for specific components, keyed by sling:resourceType — value is either the type name as a string or { "name", "title", "folder", "file", "icon" }. Explicit names win over the strategy and are claimed first (a colliding derived name on another component takes the normal collision fallback); reserved built-ins and duplicate override names are hard errors. folder places the component's generated file in a subfolder of the schemas dir (single segment, no / or .; applies in both MIGRATION_SCHEMA_LAYOUT layouts). file pins the generated file's basename exactly (no .ts), and with it the module's export const identifier — it wins over the MIGRATION_TYPE_SUFFIX_MODE=file decoration, must be identifier-like, and must be unique across components (duplicates are hard errors, whether in the config or against another component's resolved basename). icon names a @sanity/icons icon component (PascalCase ending in Icon, e.g. "ControlsIcon"); the generated schema imports it from the icon's v5 subpath module (@sanity/icons/Controls) and declares defineType({ icon }), so the component gets that icon in the Studio's insert menus, array previews, and structure lists (the consuming Studio must have @sanity/icons as a dependency — scaffolded studios do). preview overrides the generated type's Studio preview — { "title", "subtitle", "media", "count" }, all optional: title/subtitle/media are Sanity preview select paths (dot notation allowed, "items.0.title"), with title falling back to the static component title when empty and subtitle/media replacing the emitter's heuristic picks; count names a top-level array field whose item count is appended to the row title ("Accordion (3 items)"; probed via the first 10 indexes' _keys since Studio previews can't select whole arrays — arrays of objects only, "10+" cap). Unlike name, folder, file, icon, and preview are safe to change between runs, since moving a file, swapping an icon, or reshaping a preview never renames the type. Entries matching no listed component path are logged and ignored. Same set-once-before-first-import hazard as the strategy itself for name. Override the file path via AEM_COMPONENT_NAMES_FILE (default ./aem-component-names.json).
Schema layout (MIGRATION_SCHEMA_LAYOUT). Controls the on-disk shape of the generated schemas dir. flat (default) keeps every {type}.ts at the top level. kind groups files into documents/ (the page doc, per-template docs, contentFragment) and objects/ (component schemas, the page-builder array, table types, contentFragmentRef); per-component folder overrides win in both layouts. The index.ts barrel always stays at the root of the schemas dir with an identical allSchemaTypes export — imports through subfolders are internal to the barrel, so Studios never change. Not a set-once knob: switching layouts only moves files (type names and ingested _types are untouched), and the pruner removes copies left at old locations on the next run. The standalone aem-to-sanity-pagebuilder CLI honors it too (--layout overrides).
Page-builder name (MIGRATION_PAGE_BUILDER_NAME). The generated page-builder array type — and everything that references it — defaults to pageBuilder but can be renamed via this env var (e.g. sections). One value names the emitted {name}.ts file, its defineType({ name }), the field on page.ts and per-template documents, and the type container drop-zones reference. aem-transform reads the same env var to key page blocks on emitted documents, so set it once in the tenant .env before the first run and leave it alone — changing it between a schema run and a transform run desyncs the schema from the ingested content. Must be a valid identifier; page and index are rejected (file collisions in the schemas dir), as are names shadowing a Sanity built-in or a generated component type. The standalone aem-to-sanity-pagebuilder CLI honors it too (--pagebuilder-type still overrides). Frontends query the field by name — mirror a custom value in the consuming app's GROQ queries.
Content registry carries Sanity types: each entry's fields is Array<{name, type}>, not just names. aem-transform reads those types to coerce AEM values into the exact Sanity shape — for example, HTML strings on array-of-blocks fields (AEM's cq/gui/components/authoring/dialog/richtext) are converted into Portable Text at ingest. Legacy fields: string[] registries are still read, minus the coercion.
Coral buttongroup (granite/ui/components/coral/foundation/form/buttongroup). Single selection mode emits a string with options.list from the dialog's literal items (an item flagged selected becomes the initialValue) plus a non-standard options.aemWidget: "buttonGroup" marker — the example Studio's form.components.input resolver routes marked fields to a toggle-button-group input (apps/studio/components/inputs/StringToggleGroupInput.tsx); Studios without the resolver fall back to the default dropdown. Multiple selection mode emits array of string with the same list (Sanity's built-in checkbox rendering). Datasource-driven items (selects, radiogroups, and buttongroups alike) resolve when the datasource is an ACS Commons generic list (options fetched from the list page's jcr:content/list via the same transport as dialog fetches, memoized per component) or a core policy datasource (allowedheadingelements / title allowedtypes → the servlet's no-policy h1–h6 default; the template policy may allow fewer — authored values round-trip either way). Other datasources (project-custom servlets, Scene7 presets, languages) fall back to a plain field without options — values still migrate, and each fallback lands in migration-report.json → results[].unmapped as datasource-unresolved; use aem-dialog-overrides.json's dialogFile with literal items to restore those dropdowns. Buttongroups (and any other widget) referenced through a granite include whose fetched fragment root is the widget node map identically — the mapper routes a widget-rooted fragment through the normal field mapping instead of walking its option items as if they were fields.
Show/hide widgets (ACS Commons and core AEM). Dialogs using ACS AEM Commons show/hide (acs-cq-dialog-dropdown-checkbox-showhide-target in granite:data) or core AEM's stock cq-dialog-dropdown-showhide pattern (cq-dialog-dropdown-showhide-target on the select, showhidetargetvalue on each target) — a controller widget toggling nodes that carry the selector's class — emit Sanity conditional fields: every field under a target node gets hidden: ({ parent }) => … reading the controller off its sibling scope, so the Studio dialog folds the same way the AEM one did. Targets can be single widgets or whole containers; nested targets AND together. An unset controller counts as its AEM default: dropdowns fall back to the selected option, checkboxes to the checked attribute (absent or EL ${...} defaults count as unchecked). Resolution is per object scope (a top-level select can't toggle a multifield row field — same as ACS, where state only affects the current row); unmatched targets stay visible. Purely a Studio display concern — authored values migrate regardless of visibility.
Coral text (granite/ui/components/coral/foundation/text). Static author-facing copy in dialogs (instructions, inline warnings) has no name and persists nothing. It emits as a display-only note: a read-only string whose description carries the message, marked options.aemWidget: "note". The example Studio's form.components.field resolver replaces the whole field with a caution-toned banner (apps/studio/components/inputs/NoteField.tsx); Studios without the resolver show an empty read-only input with the message as its description. Nodes without a text attribute are skipped.
Container components are declared in aem-component-containers.json (override with AEM_COMPONENT_CONTAINERS_FILE). For each listed sling:resourceType, the emitter appends a synthetic defineField({ name: childrenField, type: "pageBuilder" }) (the type follows MIGRATION_PAGE_BUILDER_NAME when set) so the Studio palette inside the container mirrors the top-level page builder. The content transform consumes the same file to recursively emit drop-zone children (cq:isContainer pattern: JCR keys whose values are nodes carrying their own sling:resourceType) as nested pageBuilder blocks. Missing file → no container behavior.
Authoring hints (cq:panelTitle and friends). AEM stores some authoring metadata outside the dialog — most notably the panel heading on accordion / expander panel children, which lives on cq:panelTitle rather than a dialog property. Per-project opt-ins live in aem-component-hints.json (override with AEM_COMPONENT_HINTS_FILE); listed components get a readOnly string field declared per hint (rename vocabulary cq:panelTitle → panelTitle is global in packages/aem-to-sanity-core/src/aem/authoring-hints.ts). The content transform consumes the same file to lift the value at migration time. Non-listed components stay clean — no extra fields added, no transform-time renames.
Page-shell components (aem-page-components.json). AEM pages put their dialog on jcr:content via a "page" component (e.g. /apps/uxp/components/structure/page); a sibling cq:template identifies what kind of page it is. Declare each page-shell sling:resourceType and its templates in aem-page-components.json (override with AEM_PAGE_COMPONENTS_FILE). Two modes supported and combinable: "templates": [...] lists templates explicitly, "discover": true auto-enumerates them by scanning output/cache/aem/content/ (populated by aem-extract) for cq:template values on matching jcr:content nodes. For every (resourceType, template) pair — explicit or discovered — the emitter renders one Sanity document type (planDetailsPage.ts, newsArticlePage.ts, …) whose fields are: title / slug / tags / pageProperties (an inline object typed against the page-shell dialog) / featuredImage (image) / cqTemplate (read-only string) / pageBuilder. The page-shell object type is automatically excluded from pageBuilder.of[] since it belongs on jcr:content, not in the body. A page-templates.json manifest is written to output/cache/ so aem-transform can route each raw page to the right _type. Missing / empty file → no per-template docs, every page uses the generic page doc (fully backwards compatible). A per-template "names" map on the entry (keyed by cq:template path; string type name or { "name", "title" }, same shape as aem-component-names.json) pins the emitted _type / Studio title instead of the derived <template>Page name — useful when the template path already ends in "-page" (universal-page → universalPagePage otherwise). Explicit names claim first; reserved or colliding explicit names fail the run. Set-once-before-first-import: renaming later requires re-import with --recreate-on-type-change.
Template-restricted components (components). By default every emitted component joins the single shared pageBuilder array, so every page type offers every component in its "+ Add" menu. An aem-page-components.json entry's optional components map restricts components to specific templates — keyed by cq:template path, each value lists the component sling:resourceTypes allowed only there ({"components": {"/conf/.../templates/news-article": ["uxp/components/proxy/content/newscard"]}}). Listed components leave the shared base array; each keyed template's document type gets a dedicated generated array — {docType}Builder, base members + that template's extras, alphabetized — and its page-builder field points at it. The field name is unchanged (MIGRATION_PAGE_BUILDER_NAME, default pageBuilder), so aem-transform output and ingested content are untouched: this reshapes Studio "+ Add" menus only and is safe to add, change, or remove between runs (not a set-once knob). Like names, a components key must match a declared template unless discover: true is set; a discover-mode template never found in extracted content — and any resource type matching no emitted component — is warned and skipped, keeping the component in the shared array. Note the shared array also backs container drop zones and the generic page doc, so a restricted component disappears from those surfaces. No components maps → one shared array for every page type (fully backwards compatible).
Named-slot auto-discovery. Some AEM components nest a single fixed-name child component under a JCR key (e.g. media-paragraph.content = a content block). These aren't dialog fields and aren't drop-zones — they're slots, and they only show up in authored content. migrate:schema scans output/cache/aem/content/ after the dialog pass and emits a defineField({ name: slotKey, type: childTypeName }) for each discovered slot so the Studio shows typed inline data instead of "Unknown fields" warnings. Lone hand-named slots render collapsed (click-to-open row); component types that only ever appear as slot fills are excluded from pageBuilder.of[] (logged per exclusion — a page-body or container appearance anywhere keeps the type in the menu). Discovery needs no config. First run has no content to scan (returns empty); rerun after aem-extract and slots materialize. Container parents skip slot synthesis — their drop-zone logic already claims resourceType-carrying children. Optionally, aem-component-slots.json (override via AEM_COMPONENT_SLOTS_FILE) layers per-slot visibility on top: {"<resourceType>": {"<slotField>": {"visibleWhen": "enablePrimaryButton"}}} mirrors an AEM enable-toggle as a conditional hidden callback on the synthesized field (string shorthand for a boolean sibling, { "field", "equals" } for string matching). Display-only — slot content migrates regardless; bad rules warn and leave the slot visible.
Dialog inheritance via sling:resourceSuperType. When a component listed in aem-component-paths has no cq:dialog of its own, the migrator walks the sling:resourceSuperType chain (across /apps then /libs, AEM's lookup order) until it finds a dialog. This is what AEM does at request time to resolve which dialog opens for an authored instance — proxy components like /apps/<site>/components/proxy/foo that extend a versioned base or an Adobe Core component would otherwise fail with a 404. Resolution chains are recorded in migration-report.json under each component's supertypeChain field (omitted for direct hits) and logged at info level during the run. Registry keys remain the original proxy path's resource type — so authored content with sling:resourceType: <proxy> keeps matching its emitted Sanity type even though the dialog fields came from an ancestor.
Ejecting dialogs (aem-eject-dialogs). For components where you'd rather own the dialog outright, the aem-eject-dialogs bin (tenant script pnpm eject-dialogs <paths…|--all> [--force] [--out-dir <dir>]) writes each component's effective dialog — embedded/supertype resolution plus supplementaryTabs splicing, with resolvable datasource options baked in as literal items — to ./dialog-overrides/<resourceType>.json and rewrites the aem-dialog-overrides.json entry to { "dialogFile": … } (baked supplementaryTabs are dropped to avoid double-splicing; unrelated entries pass through untouched). Hand-edit the files to add/remove fields or pin options, then re-run migrate:schema. Existing files are skipped without --force; with it they're refreshed from AEM and hand edits are lost. Ejected dialogs no longer track AEM-side dialog changes.
Dialog overrides (aem-dialog-overrides.json). The walk is first-hit, but AEM's Sling Resource Merger also merges tabs from ancestor dialogs into a proxy's own dialog — those inherited tabs are invisible to the migrator. The optional tenant file aem-dialog-overrides.json (override via AEM_DIALOG_OVERRIDES_FILE) names them explicitly, keyed by sling:resourceType: supplementaryTabs lists absolute JCR paths of tab nodes to fetch and splice into the resolved dialog (insertAfter/insertBefore position by sibling node name, default append; key overrides the node name), and/or dialogFile points at a local JSON file holding the complete cq:dialog node, replacing resolution entirely (when both are set, the file is the base and the tabs splice on top). Missing anchors warn and append; a duplicate tab key or a dialog without a tabs container is a hard mappingError (use dialogFile for those). Applied overrides land in migration-report.json (dialogOverride, supplementaryTabs), and the output/cache/aem/apps/… snapshot stores the merged dialog. scripts/aem-probe.ts applies the same overrides, so what it prints is what the migrator maps. Entries may also carry fieldOverrides — per-field Studio tweaks keyed by emitted (camelCase) field name, applied after dialog mapping: readOnly: true locks the input, initialValue seeds Studio-created content with a JSON literal or the "uuid" sentinel (emits initialValue: () => crypto.randomUUID() — auto-generated ids like a permissions tab's componentId). A "*" config key applies its fieldOverrides to every listed component (per-component entries win per field; "*" may carry only fieldOverrides); overrides matching no mapped field are skipped silently. initialValue never touches migrated content (authored values win), and fieldOverrides is safe to change between runs.
Status: scaffold. See repo root for the refactor plan.
