← 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
A scripts/menu-nest-trim-under-fabrics.mjs
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 →