[object Object]

← back to Designerwallcoverings

upstream fix: greenland-onboard writes specs to metafields (shared _spec-to-metafields mapper) + prose-only body (no spec table)

11f2c9d9204407e4baf9c51aa34273a723c7ae2c · 2026-07-29 07:26:54 -0700 · Steve Abrams

Files touched

Diff

commit 11f2c9d9204407e4baf9c51aa34273a723c7ae2c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 29 07:26:54 2026 -0700

    upstream fix: greenland-onboard writes specs to metafields (shared _spec-to-metafields mapper) + prose-only body (no spec table)
---
 scripts/_spec-to-metafields.mjs              | 122 +++++++++++++++++++++++++++
 scripts/greenland-onboard/build-payloads.mjs |  23 ++---
 2 files changed, 134 insertions(+), 11 deletions(-)

diff --git a/scripts/_spec-to-metafields.mjs b/scripts/_spec-to-metafields.mjs
new file mode 100644
index 0000000..73a6a1b
--- /dev/null
+++ b/scripts/_spec-to-metafields.mjs
@@ -0,0 +1,122 @@
+// _spec-to-metafields.mjs — shared canonical spec-row → metafield mapper for the
+// *-onboard/build-payloads.mjs generators. Mirrors ~/dw-infinite-scroll-debug/
+// specfix-all.mjs so NEW products land specs in the exact metafields the theme
+// (snippets/product-description-meta.liquid) renders — never baked into body_html.
+//
+// Usage in a generator:
+//   import { specMetafields } from '../_spec-to-metafields.mjs';
+//   const rows = [['Pattern', name], ['Width', w], ...];   // [label, value] pairs
+//   const mf = [...specMetafields(rows), ...otherMetafields];  // then body = prose only
+//
+// Types respected: custom.material is multi_line; product_reference custom.* keys
+// (finish/match/backing/etc.) route to their global.* / specs.* text twin.
+
+const T = {
+  pattern_name:      { ns:'custom', key:'pattern_name',      type:'single_line_text_field' },
+  color:             { ns:'custom', key:'color',             type:'single_line_text_field' },
+  material:          { ns:'custom', key:'material',          type:'multi_line_text_field'  },
+  composition:       { ns:'specs',  key:'composition',       type:'multi_line_text_field'  },
+  width:             { ns:'custom', key:'width',             type:'single_line_text_field' },
+  collection_name:   { ns:'custom', key:'collection_name',   type:'single_line_text_field' },
+  pattern_repeat:    { ns:'custom', key:'pattern_repeat',    type:'single_line_text_field' },
+  fire_rating:       { ns:'custom', key:'fire_rating',       type:'single_line_text_field' },
+  manufacturer_sku:  { ns:'custom', key:'manufacturer_sku',  type:'single_line_text_field' },
+  brand:             { ns:'custom', key:'brand',             type:'single_line_text_field' },
+  designer:          { ns:'custom', key:'designer',          type:'single_line_text_field' },
+  match:             { ns:'global', key:'Match',             type:'single_line_text_field' },
+  finish:            { ns:'global', key:'Finish',            type:'single_line_text_field' },
+  backing:           { ns:'global', key:'Substrate',         type:'single_line_text_field' },
+  weight:            { ns:'global', key:'Weight',            type:'single_line_text_field' },
+  length:            { ns:'custom', key:'length',            type:'single_line_text_field' },
+  cleaning:          { ns:'global', key:'Cleaning',          type:'single_line_text_field' },
+  application:       { ns:'global', key:'application',       type:'single_line_text_field' },
+  origin:            { ns:'global', key:'Country',           type:'single_line_text_field' },
+  style:             { ns:'global', key:'Style',             type:'single_line_text_field' },
+  uom:               { ns:'global', key:'unit_of_measure',   type:'single_line_text_field' },
+  packaged:          { ns:'global', key:'packaged',          type:'single_line_text_field' },
+  certifications:    { ns:'custom', key:'certifications',    type:'single_line_text_field' },
+  grammage:          { ns:'custom', key:'grammage',          type:'single_line_text_field' },
+  light_fastness:    { ns:'custom', key:'light_fastness',    type:'single_line_text_field' },
+  washability:       { ns:'custom', key:'washability',       type:'single_line_text_field' },
+  removal:           { ns:'custom', key:'removal',           type:'single_line_text_field' },
+  adhesive:          { ns:'custom', key:'adhesive',          type:'single_line_text_field' },
+  coverage:          { ns:'custom', key:'coverage',          type:'single_line_text_field' },
+  wall_coverage:     { ns:'custom', key:'wall_coverage',     type:'single_line_text_field' },
+  grade:             { ns:'custom', key:'grade',             type:'single_line_text_field' },
+  leadtime:          { ns:'custom', key:'leadtime',          type:'single_line_text_field' },
+  dimensions:        { ns:'custom', key:'dimensions',        type:'single_line_text_field' },
+  horizontal_repeat: { ns:'custom', key:'horizontal_repeat', type:'single_line_text_field' },
+  prop65:            { ns:'custom', key:'prop65',            type:'single_line_text_field' },
+  catb117:           { ns:'custom', key:'catb117',           type:'single_line_text_field' },
+  min_order:         { ns:'custom', key:'min_order',         type:'single_line_text_field' },
+};
+const ALIAS = {
+  pattern:'pattern_name', 'pattern name':'pattern_name', design:'pattern_name', name:'pattern_name',
+  color:'color', colour:'color', colorway:'color', 'color name':'color',
+  material:'material', content:'material', contents:'material', 'material / content':'material', 'fabric content':'material',
+  composition:'composition',
+  width:'width', 'roll width':'width', 'fabric width':'width',
+  collection:'collection_name', 'collection name':'collection_name', book:'collection_name',
+  'pattern repeat':'pattern_repeat', repeat:'pattern_repeat', 'vertical repeat':'pattern_repeat', 'design repeat':'pattern_repeat', 'repeat v':'pattern_repeat',
+  'pattern match':'match', match:'match', 'match type':'match',
+  finish:'finish',
+  'fire rating':'fire_rating', flammability:'fire_rating', flame:'fire_rating', 'flame spread':'fire_rating',
+  backing:'backing', substrate:'backing',
+  weight:'weight', 'product weight':'weight',
+  length:'length', 'roll length':'length', 'bolt length':'length', bolt:'length',
+  care:'cleaning', cleaning:'cleaning', 'clean code':'cleaning',
+  application:'application', use:'application', usage:'application',
+  origin:'origin', 'country of origin':'origin', country:'origin',
+  brand:'brand', designer:'designer', 'design by':'designer', 'designed by':'designer',
+  'manufacturer sku':'manufacturer_sku', sku:'manufacturer_sku', 'item number':'manufacturer_sku', 'product code':'manufacturer_sku', 'pattern number':'manufacturer_sku',
+  style:'style',
+  'unit of measure':'uom', 'sold by':'uom', 'sold per':'uom', 'priced per':'uom', uom:'uom',
+  packaging:'packaged', packaged:'packaged',
+  certifications:'certifications', certification:'certifications',
+  grammage:'grammage', gsm:'grammage',
+  'light fastness':'light_fastness', lightfastness:'light_fastness',
+  washability:'washability', washable:'washability',
+  removal:'removal', removability:'removal',
+  adhesive:'adhesive', adhesion:'adhesive', paste:'adhesive',
+  coverage:'coverage', 'wall coverage':'wall_coverage',
+  grade:'grade',
+  'lead time':'leadtime', 'lead-time':'leadtime', leadtime:'leadtime',
+  dimensions:'dimensions', dimension:'dimensions',
+  'horizontal repeat':'horizontal_repeat', 'horz repeat':'horizontal_repeat',
+  'prop 65':'prop65', prop65:'prop65', 'proposition 65':'prop65',
+  'ca tb117':'catb117', tb117:'catb117', 'ca tb 117':'catb117',
+  'minimum order':'min_order', 'min order':'min_order', moq:'min_order', minimum:'min_order',
+};
+// labels intentionally NOT migrated (redundant with product_type or native fields)
+const DROP = new Set(['type','product type','category','product category','pricing','price','sample size']);
+const NULLVAL = new Set(['','none','n a','na','n/a','-','--','tbd','see below']);
+
+function norm(label){
+  return String(label).toLowerCase().replace(/&amp;/g,'&').replace(/[.:#*\/]/g,' ').replace(/\(.*?\)/g,' ').replace(/\s+/g,' ').trim();
+}
+function decodeEntities(s){
+  return String(s).replace(/&nbsp;/g,' ').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"').replace(/&#0?39;|&apos;/g,"'").replace(/&#(\d+);/g,(m,n)=>{try{return String.fromCharCode(+n)}catch(e){return m}}).replace(/&amp;/g,'&');
+}
+
+// rows: array of [label, value]. Returns array of {namespace,key,type,value}.
+// Skips DROP labels, blank/null values, and de-dups so two labels can't clobber
+// the same target (first wins).
+export function specMetafields(rows){
+  const out = []; const used = new Set();
+  for(const [label, rawVal] of rows){
+    if(rawVal == null) continue;
+    const val = decodeEntities(rawVal).trim();
+    if(NULLVAL.has(val.toLowerCase())) continue;
+    const n = norm(label);
+    if(DROP.has(n)) continue;
+    const ck = ALIAS[n];
+    if(!ck) continue;                    // unknown label → skip (won't render anyway)
+    const t = T[ck];
+    const id = t.ns + '.' + t.key;
+    if(used.has(id)) continue;           // first label wins for a given target
+    used.add(id);
+    out.push({ namespace:t.ns, key:t.key, type:t.type, value:val });
+  }
+  return out;
+}
+export default specMetafields;
diff --git a/scripts/greenland-onboard/build-payloads.mjs b/scripts/greenland-onboard/build-payloads.mjs
index 2d71fa5..f53979e 100644
--- a/scripts/greenland-onboard/build-payloads.mjs
+++ b/scripts/greenland-onboard/build-payloads.mjs
@@ -9,6 +9,7 @@ import fs from 'node:fs';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
+import { specMetafields } from '../_spec-to-metafields.mjs';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
@@ -39,28 +40,28 @@ function tags(r) {
   if (r.material) t.add(titleCase(r.material));
   return [...t].join(', ');
 }
-function bodyHtml(r) {
-  const rows = [
+// spec rows → migrated to metafields (theme renders them); NOT baked into body_html
+function specRows(r) {
+  return [
     ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material || 'Cork'],
     ['Composition', r.composition], ['Width', r.width], ['Minimum Order', r.minimum_order],
     ['Repeat', r.pattern_repeat], ['Backing', r.backing], ['Fire Rating', r.fire_rating_us],
     ['Manufacturer SKU', r.mfr_sku], ['Collection', /tip\s*cards?/i.test(r.collection||'') ? '' : r.collection],
   ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
+function bodyHtml(r) {
   const pat = titleCase(clean(r.pattern_name || r.mfr_sku));
-  return `<p>${pat} from Greenland's natural cork collection — a warm, tactile cork wallcovering. Priced by quote; request a memo sample to see the material in hand.</p>`
-    + `<table>${rows.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${String(v)}</td></tr>`).join('')}</table>`;
+  // PROSE ONLY — specs live in metafields (rendered by product-description-meta.liquid), never a body table
+  return `<p>${pat} from Greenland's natural cork collection — a warm, tactile cork wallcovering. Priced by quote; request a memo sample to see the material in hand.</p>`;
 }
 function metafields(r) {
-  const mf = [];
-  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '') mf.push({ namespace: ns, key, type, value: String(value) }); };
-  S('custom', 'manufacturer_sku', r.mfr_sku);
+  const mf = [...specMetafields(specRows(r))];               // canonical spec metafields (Material/Composition/Backing/Fire Rating/etc.)
+  const have = new Set(mf.map(m => m.namespace + '.' + m.key));
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !have.has(ns + '.' + key)) { mf.push({ namespace: ns, key, type, value: String(value) }); have.add(ns + '.' + key); } };
   S('global', 'Brand', 'Greenland');
-  S('global', 'width', r.width);
-  S('global', 'repeat', r.pattern_repeat);
   S('global', 'unit_of_measure', 'Priced Per Roll (Quote)');
-  S('custom', 'color', r.color_name);
   S('custom', 'real_color_name', r.color_name);
-  if (r.width_inches != null) mf.push({ namespace: 'global', key: 'width_inches', type: 'number_decimal', value: String(r.width_inches) });
+  if (r.width_inches != null) S('global', 'width_inches', String(r.width_inches), 'number_decimal');
   return mf;
 }
 

← d3defbb auto-save: 2026-07-29T05:36:12 (2 files) — scripts/kravet-sp  ·  back to Designerwallcoverings  ·  upstream fix (TK-10039): 8 more onboard generators write spe 2ac6ccd →