import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' const root = process.cwd() const contentRoot = join(root, 'data', 'content') const outRoot = join(root, 'content_population_exports') const assetValidationPath = join(outRoot, 'asset_manifest_validation.json') const clinicalBlankDecisionPath = join(contentRoot, 'compliance', 'clinical-blank-decisions.json') const productColumns = [ 'Handle', 'Metafield: filter.dietary_tag [list.metaobject_reference]', 'Metafield: filter.health_goals [list.metaobject_reference]', 'Metafield: pdp.key_ingredients [metaobject_reference]', 'Metafield: pdp.benefits [metaobject_reference]', 'Metafield: pdp.clinically_shown_to [metaobject_reference]', 'Metafield: breadcrumb.primary_collection [collection_reference]', 'Metafield: filter.strength [single_line_text_field]', 'Metafield: pdp.who_its_for [single_line_text_field]', 'Metafield: pdp.product_benefits [list.single_line_text_field]', 'Metafield: pdp.key_features [multi_line_text_field]', 'Metafield: pdp.results [file_reference]', 'Metafield: pdp.results_mobile [file_reference]', 'Metafield: pdp.how_we_compare [file_reference]', 'Metafield: faq.heading_one [single_line_text_field]', 'Metafield: faq.heading_two [single_line_text_field]', 'Metafield: faq.heading_three [single_line_text_field]', 'Metafield: faq.answer_one [multi_line_text_field]', 'Metafield: faq.answer_two [multi_line_text_field]', 'Metafield: faq.answer_three [multi_line_text_field]', 'Variant SKU', 'Variant Metafield: pdp.tag [single_line_text_field]', 'Variant Metafield: pdp.colour [color]', 'Variant Metafield: pdp.price_per [single_line_text_field]', 'Variant Metafield: pdp.strapline [single_line_text_field]' ] const metaColumns = ['Handle', 'Display Name', 'Status', 'Definition: Handle', 'Definition: Name', 'Field', 'Value'] const metaDefinitionNames = { dietary_tag: 'Dietary Tag', health_goals: 'Health Goals', key_ingredients: 'Key Ingredients', benefits: 'Full Description - Benefits', clinically_shown_to: 'Clinically Shown To...', promo_card: 'Promo Card (Collections)' } function csvEscape(value) { if (Array.isArray(value)) value = value.join(', ') if (value === undefined || value === null) value = '' value = String(value) return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value } function toCsv(rows) { return rows.map(row => row.map(csvEscape).join(',')).join('\n') + '\n' } async function readJson(path) { return JSON.parse(await readFile(path, 'utf8')) } async function readJsonFallback(path, fallback) { try { return await readJson(path) } catch (_) { return fallback } } function asList(value) { if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean) if (value === undefined || value === null || value === '') return [] if (typeof value === 'string') return value.split(',').map(v => v.trim()).filter(Boolean) return [String(value)] } function countBy(rows, key) { return rows.reduce((acc, row) => { const value = row[key] || 'unknown' acc[value] = (acc[value] || 0) + 1 return acc }, {}) } function uniqueSkus(rows) { return [...new Set(rows.map(row => row.sku).filter(Boolean))].sort() } function isApprovedBlankClinicalDecision(decision) { return decision?.decision === 'approved_blank' && Boolean(decision.approvedBy) && Boolean(decision.approvedAt) && Boolean(decision.rationale) && decision.constraintsAcknowledged === true } await mkdir(outRoot, { recursive: true }) const generatedAt = new Date().toISOString() const assetValidation = await readJsonFallback(assetValidationPath, null) const clinicalBlankDecisions = await readJsonFallback(clinicalBlankDecisionPath, { decisions: [] }) const clinicalBlankDecisionBySku = new Map((clinicalBlankDecisions.decisions || []).map(row => [row.sku, row])) const productFiles = (await readdir(join(contentRoot, 'products'))).filter(f => f.endsWith('.json')) const products = [] for (const file of productFiles) products.push(await readJson(join(contentRoot, 'products', file))) products.sort((a, b) => a.sku.localeCompare(b.sku)) const productRows = [productColumns] const blockers = [] for (const product of products) { const m = product.metafields || {} if (!m.pdpClinicallyShownTo && !isApprovedBlankClinicalDecision(clinicalBlankDecisionBySku.get(product.sku))) { blockers.push({ sku: product.sku, type: 'compliance', blocker: 'Missing pdp.clinically_shown_to or approved blank compliance decision' }) } for (const field of ['pdpResults', 'pdpResultsMobile', 'pdpHowWeCompare']) { if (!m[field]) blockers.push({ sku: product.sku, type: 'asset', blocker: `Missing ${field}` }) } if (!product.approval || product.approval.content !== 'approved') { blockers.push({ sku: product.sku, type: 'approval', blocker: 'Content not approved' }) } ;(product.variants || [{ sku: product.sku }]).forEach((variant, index) => { const first = index === 0 productRows.push([ product.handle, first ? asList(m.filterDietaryTag).join(', ') : '', first ? asList(m.filterHealthGoals).join(', ') : '', first ? m.pdpKeyIngredients : '', first ? m.pdpBenefits : '', first ? m.pdpClinicallyShownTo : '', first ? m.breadcrumbPrimaryCollection : '', first ? m.filterStrength : '', first ? m.pdpWhoItsFor : '', first ? JSON.stringify(asList(m.pdpProductBenefits)) : '', first ? m.pdpKeyFeatures : '', first ? m.pdpResults : '', first ? m.pdpResultsMobile : '', first ? m.pdpHowWeCompare : '', first ? m.faqHeadingOne : '', first ? m.faqHeadingTwo : '', first ? m.faqHeadingThree : '', first ? m.faqAnswerOne : '', first ? m.faqAnswerTwo : '', first ? m.faqAnswerThree : '', variant.sku, variant.pdpTag, variant.pdpColour, variant.pdpPricePer, variant.pdpStrapline ]) }) } if (assetValidation?.missingReferences?.length) { for (const item of assetValidation.missingReferences) { blockers.push({ sku: item.Owner || item.owner || '', type: 'asset_reference', blocker: `${item.Field || item.field} filename missing` }) } } if (assetValidation?.required?.length) { for (const item of assetValidation.required.filter((row) => (row.Exists || row.exists) === 'no' || row.exists === false)) { blockers.push({ sku: item.Owner || item.owner || '', type: 'asset_upload', blocker: `${item.Field || item.field} file not found/uploaded: ${item.Filename || item.filename}` }) } } await writeFile(join(outRoot, 'products_content_matrixify.csv'), toCsv(productRows), 'utf8') const meta = await readJson(join(contentRoot, 'metaobjects.json')) const metaRows = [metaColumns] const metaobjectCounts = {} for (const [definition, rows] of Object.entries(meta.definitions || {})) { metaobjectCounts[definition] = rows.length for (const row of rows) { const handle = row.handle || row.name_internal || row.name const displayName = row.name || row.heading || row.name_internal || handle const status = ['active', 'approved', 'ready'].includes(String(row.status || '').toLowerCase()) ? 'Active' : 'Draft' const definitionName = metaDefinitionNames[definition] || definition for (const [field, value] of Object.entries(row)) { if (field === 'handle' || field === 'status' || field === 'source') continue metaRows.push([handle, displayName, status, definition, definitionName, field, value]) } } } await writeFile(join(outRoot, 'metaobjects_matrixify.csv'), toCsv(metaRows), 'utf8') const collections = await readJson(join(contentRoot, 'smart-collections.json')) const collectionRows = [['Handle', 'Metafield: custom.promo_card [metaobject_reference]'], ...(collections.collections || []).map(c => [c.handle, c.promoCard])] await writeFile(join(outRoot, 'smart_collections_matrixify.csv'), toCsv(collectionRows), 'utf8') await writeFile(join(outRoot, 'export_blockers.csv'), toCsv([['SKU', 'Type', 'Blocker'], ...blockers.map(b => [b.sku, b.type, b.blocker])]), 'utf8') const blockersByType = countBy(blockers, 'type') const blockedSkus = uniqueSkus(blockers) const manifest = { generatedAt, readyForLewis: blockers.length === 0, source: 'jv-dashboard/data/content', outputs: { products: 'products_content_matrixify.csv', metaobjects: 'metaobjects_matrixify.csv', smartCollections: 'smart_collections_matrixify.csv', blockers: 'export_blockers.csv', handoffManifest: 'handoff_manifest.json', handoffSummary: 'handoff_summary.md' }, counts: { productRecords: products.length, variantRows: productRows.length - 1, metaobjectRows: metaRows.length - 1, smartCollectionRows: collectionRows.length - 1, blockers: blockers.length, blockedSkus: blockedSkus.length }, blockerSummary: blockersByType, clinicalBlankDecisions: { decisionFile: 'data/content/compliance/clinical-blank-decisions.json', validBlankApprovals: [...clinicalBlankDecisionBySku.values()].filter(isApprovedBlankClinicalDecision).length, totalDecisionRows: clinicalBlankDecisionBySku.size }, metaobjectCounts, blockedSkus, nextActions: [ blockersByType.compliance ? 'Add source-backed clinical claims and map pdp.clinically_shown_to metaobjects, or record approved_blank compliance decisions for SKUs that should intentionally omit the module.' : null, blockersByType.asset ? 'Assign/produce PDP result, mobile result, and comparison filenames, then upload to Shopify Files before import.' : null, blockersByType.approval ? 'Complete dashboard human review and mark content approved before handoff.' : null, 'Run migration folder Matrixify emitters and staging import spot-checks with Lewis/Euan.' ].filter(Boolean) } await writeFile(join(outRoot, 'handoff_manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf8') const summary = [ '# JV Shopify Matrixify handoff summary', '', `Generated: ${generatedAt}`, '', `Ready for Lewis/Euan: **${manifest.readyForLewis ? 'YES' : 'NO'}**`, '', '## Output files', ...Object.values(manifest.outputs).map(file => `- ${file}`), '', '## Counts', `- Product records: ${manifest.counts.productRecords}`, `- Product/variant CSV rows: ${manifest.counts.variantRows}`, `- Metaobject rows: ${manifest.counts.metaobjectRows}`, `- Smart collection rows: ${manifest.counts.smartCollectionRows}`, `- Blockers: ${manifest.counts.blockers}`, `- Blocked SKUs: ${manifest.counts.blockedSkus}`, '', '## Blockers by type', ...Object.entries(blockersByType).map(([type, count]) => `- ${type}: ${count}`), ...(Object.keys(blockersByType).length ? [] : ['- None']), '', '## Next actions', ...manifest.nextActions.map(action => `- ${action}`), '' ].join('\n') await writeFile(join(outRoot, 'handoff_summary.md'), summary, 'utf8') console.log(`Wrote ${outRoot}`) console.log(`${products.length} product records, ${blockers.length} blockers`) console.log(`Handoff ready: ${manifest.readyForLewis ? 'yes' : 'no'}`)