[object Object]

← back to Designer Wallcoverings

nav: script to re-nest Trim under Fabrics in main-menu (dry-run default, needs nav-scoped token)

da0bc8cb4f362ab7fa43630e33e35af1e1f14951 · 2026-07-30 10:23:24 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit da0bc8cb4f362ab7fa43630e33e35af1e1f14951
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 10:23:24 2026 -0700

    nav: script to re-nest Trim under Fabrics in main-menu (dry-run default, needs nav-scoped token)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/menu-nest-trim-under-fabrics.mjs | 202 +++++++++++++++++++++++++++++++
 1 file changed, 202 insertions(+)

diff --git a/scripts/menu-nest-trim-under-fabrics.mjs b/scripts/menu-nest-trim-under-fabrics.mjs
new file mode 100644
index 00000000..046e1801
--- /dev/null
+++ b/scripts/menu-nest-trim-under-fabrics.mjs
@@ -0,0 +1,202 @@
+#!/usr/bin/env node
+/**
+ * menu-nest-trim-under-fabrics.mjs
+ * ---------------------------------
+ * Re-nests the "Trim" item to be a CHILD of "Fabrics" in the DW store's
+ * Online Store navigation (default linklist handle: main-menu).
+ *
+ * The theme (theme-LIVE-591/sections/header.liquid) renders the mega-menu
+ * straight from linklists['main-menu'].links, so this menu re-nest is the
+ * ONLY change needed — no theme edit, no deploy.
+ *
+ * SAFE BY DEFAULT: dry-run. It prints the current tree and the planned tree
+ * but does NOT write anything unless you pass --apply.
+ *
+ * Requires a Shopify Admin token with the online-store-navigation scope
+ * (read_online_store_navigation + write_online_store_navigation). The current
+ * DW project tokens are DENIED on the menus field — drop a nav-scoped token in
+ * as SHOPIFY_NAV_TOKEN (preferred) or SHOPIFY_ADMIN_TOKEN and re-run.
+ *
+ * Usage:
+ *   node scripts/menu-nest-trim-under-fabrics.mjs             # dry-run (default)
+ *   node scripts/menu-nest-trim-under-fabrics.mjs --apply     # actually write
+ *   node scripts/menu-nest-trim-under-fabrics.mjs --menu=main-menu --child="Trim" --parent="Fabrics"
+ *
+ * Cost: $0 (Shopify Admin API calls are free).
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..');
+
+// ---- minimal .env loader (root .env + shopify/.env; process.env wins) -------
+function loadEnv(file) {
+  try {
+    for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+      if (!m) continue;
+      const k = m[1];
+      let v = m[2].replace(/^["']|["']$/g, '').replace(/\s+#.*$/, '').trim();
+      if (v && process.env[k] === undefined) process.env[k] = v;
+    }
+  } catch { /* file may not exist */ }
+}
+loadEnv(path.join(ROOT, '.env'));
+loadEnv(path.join(ROOT, 'shopify', '.env'));
+
+// ---- args -------------------------------------------------------------------
+const args = Object.fromEntries(
+  process.argv.slice(2).map((a) => {
+    const m = a.match(/^--([^=]+)(?:=(.*))?$/);
+    return m ? [m[1], m[2] === undefined ? true : m[2]] : [a, true];
+  })
+);
+const APPLY = !!args.apply;
+const MENU_HANDLE = args.menu || 'main-menu';
+const CHILD_TITLE = (args.child || 'Trim').toString();
+const PARENT_TITLE = (args.parent || 'Fabrics').toString();
+
+const DOMAIN =
+  process.env.SHOPIFY_STORE_DOMAIN ||
+  process.env.SHOPIFY_SHOP_URL ||
+  'designer-laboratory-sandbox.myshopify.com';
+const TOKEN =
+  process.env.SHOPIFY_NAV_TOKEN ||
+  process.env.SHOPIFY_ADMIN_TOKEN ||
+  process.env.SHOPIFY_ADMIN_ACCESS_TOKEN ||
+  process.env.SHOPIFY_ADMIN_API_TOKEN;
+const API_VERSION = process.env.SHOPIFY_ADMIN_API_VERSION || '2024-10';
+
+if (!TOKEN) {
+  console.error('✗ No Shopify token found (SHOPIFY_NAV_TOKEN / SHOPIFY_ADMIN_TOKEN).');
+  process.exit(1);
+}
+
+// ---- GraphQL helper ---------------------------------------------------------
+async function gql(query, variables) {
+  const res = await fetch(`https://${DOMAIN}/admin/api/${API_VERSION}/graphql.json`, {
+    method: 'POST',
+    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query, variables }),
+  });
+  const json = await res.json();
+  if (json.errors) {
+    const msg = json.errors.map((e) => e.message).join('; ');
+    if (/Access denied for menus field/i.test(msg)) {
+      console.error(
+        `✗ Token lacks the online-store-navigation scope (${msg}).\n` +
+          '  Provision a token with read_online_store_navigation + write_online_store_navigation\n' +
+          '  and set it as SHOPIFY_NAV_TOKEN, then re-run.'
+      );
+      process.exit(2);
+    }
+    throw new Error('GraphQL error: ' + msg);
+  }
+  return json.data;
+}
+
+// ---- read the menu ----------------------------------------------------------
+const READ = `
+  query {
+    menus(first: 50) {
+      nodes {
+        id handle title
+        items { ...I items { ...I items { ...I } } }
+      }
+    }
+  }
+  fragment I on MenuItem { id title type url resourceId tags }
+`;
+
+// Convert a read MenuItem into a MenuItemUpdateInput, preserving type and the
+// correct target field (resourceId for resource-backed items, url for HTTP).
+function toInput(node) {
+  const out = { title: node.title, type: node.type };
+  if (node.tags && node.tags.length) out.tags = node.tags;
+  if (node.resourceId) out.resourceId = node.resourceId; // COLLECTION/PAGE/etc.
+  else if (node.url) out.url = node.url; // HTTP links
+  if (node.items && node.items.length) out.items = node.items.map(toInput);
+  return out;
+}
+
+function printTree(items, depth = 0) {
+  for (const it of items) {
+    console.log('  '.repeat(depth + 1) + '• ' + it.title + '  ' + (it.url || it.resourceId || ''));
+    if (it.items && it.items.length) printTree(it.items, depth + 1);
+  }
+}
+
+const norm = (s) => (s || '').trim().toLowerCase();
+
+(async () => {
+  console.log(`\nStore: ${DOMAIN}   Menu: ${MENU_HANDLE}   Mode: ${APPLY ? 'APPLY (will write)' : 'DRY-RUN'}`);
+  const data = await gql(READ);
+  const menu = data.menus.nodes.find((m) => m.handle === MENU_HANDLE);
+  if (!menu) {
+    console.error(`✗ Menu with handle "${MENU_HANDLE}" not found. Available: ` +
+      data.menus.nodes.map((m) => m.handle).join(', '));
+    process.exit(3);
+  }
+
+  console.log('\n=== CURRENT ===');
+  printTree(menu.items);
+
+  // Build the editable tree.
+  const items = menu.items.map(toInput);
+
+  // Locate the top-level Trim item and the top-level Fabrics item.
+  const childIdx = items.findIndex((it) => norm(it.title) === norm(CHILD_TITLE));
+  const parent = items.find((it) => norm(it.title) === norm(PARENT_TITLE));
+
+  if (childIdx === -1) {
+    console.error(`\n✗ Top-level "${CHILD_TITLE}" item not found in ${MENU_HANDLE}. ` +
+      'It may already be nested, or named differently — pass --child="Exact Title".');
+    process.exit(4);
+  }
+  if (!parent) {
+    console.error(`\n✗ Top-level "${PARENT_TITLE}" item not found in ${MENU_HANDLE}. ` +
+      `Create a "${PARENT_TITLE}" menu item first, or pass --parent="Exact Title".`);
+    process.exit(5);
+  }
+
+  // Move Trim under Fabrics.
+  const [childNode] = items.splice(childIdx, 1);
+  parent.items = parent.items || [];
+  if (parent.items.some((it) => norm(it.title) === norm(CHILD_TITLE))) {
+    console.log(`\n✓ "${CHILD_TITLE}" is already a child of "${PARENT_TITLE}" — nothing to do.`);
+    process.exit(0);
+  }
+  parent.items.push(childNode);
+
+  console.log('\n=== PLANNED ===');
+  printTree(items);
+
+  if (!APPLY) {
+    console.log('\nDRY-RUN only — no changes written. Re-run with --apply to commit this menu.');
+    process.exit(0);
+  }
+
+  const MUT = `
+    mutation ($id: ID!, $title: String!, $handle: String!, $items: [MenuItemUpdateInput!]!) {
+      menuUpdate(id: $id, title: $title, handle: $handle, items: $items) {
+        menu { id handle }
+        userErrors { field message }
+      }
+    }
+  `;
+  const res = await gql(MUT, { id: menu.id, title: menu.title, handle: menu.handle, items });
+  const ue = res.menuUpdate.userErrors;
+  if (ue && ue.length) {
+    console.error('\n✗ menuUpdate userErrors:');
+    for (const e of ue) console.error('   -', (e.field || []).join('.'), e.message);
+    process.exit(6);
+  }
+  console.log(`\n✓ Menu "${menu.handle}" updated — "${CHILD_TITLE}" is now under "${PARENT_TITLE}".`);
+  console.log('  The mega-menu reflects it immediately (theme reads the linklist live).');
+})().catch((e) => {
+  console.error('✗', e.message);
+  process.exit(1);
+});

← 9843ad52 TK-10053: dedup mosaic tiles by image id (Cody gate); re-sta  ·  back to Designer Wallcoverings  ·  auto-save: 2026-07-30T10:47:06 (1 files) — shopify/scripts/d 46b9ce9f →