Appearance
Drupal taxonomy → app-facet mapping (#344)
Maps a nanawall.com taxonomy term (a classification value carried by ingested project/content — an application, building type, geography, aesthetic, … term) onto an app facet: one of the app's own attributes or factors. This makes the app's attributes/factors the single shared vocabulary ingested content is filtered and matched by — so ingested content can be routed to the same facets the guided flow already uses.
The typed source of truth is src/lib/server/admin/facet-mappings.ts; the table is drupal_facet_mappings.
Three distinct mapping models — do not conflate
This is the third Drupal mapping model. Each pairs different things; they are siblings, not extensions of one another:
| Model | Table | Pairs | Owner |
|---|---|---|---|
| Field mapping (#130) | drupal_attribute_mappings | Drupal contract field → product attribute (which fields feed attributes) | drupal-product-contract.md |
| System mapping (#121) | drupal_system_mappings | products-vocab term name → system (which product term is which system) | — |
| Facet mapping (#344) | drupal_facet_mappings | taxonomy term → app facet (attribute or factor) | this doc |
The key contrast the admin surfaces call out: field mapping chooses which Drupal fields feed attributes; facet mapping maps taxonomy terms onto facets. Different source (a term vs a field), different target (a facet vs an attribute only).
The model
drupal_facet_mappings is keyed by (vocabulary, term_name) — the stable, human-authored join key, mirroring drupal_system_mappings' name-keying. term_id / term_uuid are resolved by the fetch layer on first contact and cached so later syncs address terms directly.
The facet target is exactly one of two nullable FKs:
attribute_id→attributes.idfactor_id→factors.id
A table CHECK — (attribute_id IS NULL) <> (factor_id IS NULL) — enforces exactly-one, so a term resolves to one facet, never both or neither. Both FKs ON DELETE CASCADE, so retiring the underlying attribute/factor drops the now-dangling mapping rather than leaving it pointing at nothing.
Source-term inventory — surfaced, never dropped
The candidate terms are not a frozen list. FACET_SOURCE_VOCABULARIES declares each source vocabulary and how to read its terms live from ingested content (config-driven, mirroring the content registry):
| Vocabulary | Live source | Note |
|---|---|---|
projects | drupal_projects.name | The project classification #345 matches a buyer's project against. |
products | drupal_project_products.product_name | The product terms content references. Distinct target from drupal_system_mappings (term → system): a product term can also be a filterable app facet here. |
listSourceTerms(db) derives the inventory on every call and cross-references the mappings, so a term that appears after a future sync automatically shows up as unmapped — surfaced for attention, never silently dropped. A mapping whose term the inventory no longer carries stays visible (from the mappings table) so a stale mapping can be seen and removed. Ordered vocabulary → unmapped-first → term.
Admin surface
The Taxonomy → facet mapping panel on /admin/drupal-sync (extending the existing sync surface #112, consistent with the managed-category pattern #340):
- lists every source term with its mapped facet (or
unmapped); - assigns a facet via one
<select>offering both sets (Attributes / Factors optgroups; the value encodesattribute:<id>/factor:<id>). A placeholder- first guard means a mapped row never defaults to a real facet, so a stray "Change" can't silently re-point it; - removes a mapping via a confirm step; the term reverts to unmapped.
mapFacet / unmapFacet actions delegate to setFacetMapping / removeFacetMapping (which validate the facet exists and enforce exactly-one-target) and audit each write.
Shared vocabulary — the consumer seam
getSharedFacetVocabulary(db) returns a FacetVocabularyIndex — the single seam content filtering and guided selection resolve ingested content through:
resolveTerm({ vocabulary, termName })→ the facet, ornullwhen unmapped;resolveContentFacets(refs)→ the distinct facets a content item's terms resolve to (unmapped terms dropped, facets de-duped);termsForFacet(kind, id)→ the reverse "filter content by this facet" lookup.
The index is built by the pure indexFacetVocabulary(entries) over the resolved mappings, so the resolution semantics are unit-tested without a DB. #345 (real-project matching) is the first consumer: it resolves a buyer's project terms to facets and finds installed projects sharing them.
See also
- drupal-content-registry.md — how ingested content (whose terms this maps) is declared and run.
- drupal-product-contract.md — the field-mapping (#130) sibling model.