npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 / defineField TypeScript 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-auth

Flags:

  • --verbose / -v (or AEM_VERBOSE=true) — elevate logger to debug; surfaces every GET {url} + Sling .N.json depth-fallback retries issued by aem-to-sanity-core's AEM fetcher.
  • --continue-on-auth (or AEM_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 on N consecutive 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/cardcontainerproxyContentCardcontainer) — 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 (proxyContentHeroproxyContentHeroBlock 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 h1h6 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:panelTitlepanelTitle 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-pageuniversalPagePage 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.