@runwell/shopify-toolkit 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import { add } from '../lib/add.js';
7
7
  import { remove } from '../lib/remove.js';
8
8
  import { doctor } from '../lib/doctor.js';
9
9
  import { validate } from '../lib/validate.js';
10
+ import { qa } from '../lib/qa.js';
10
11
 
11
12
  const __filename = fileURLToPath(import.meta.url);
12
13
  const __dirname = path.dirname(__filename);
@@ -45,6 +46,9 @@ Commands:
45
46
  remove <module> Disable + clean a module's files
46
47
  doctor Detect drift between manifest and theme
47
48
  validate Run @shopify/dev-mcp validators on synced files
49
+ qa Code-first QA pipeline (validate-config, doctor,
50
+ template-integrity, orphan-assets, theme-check).
51
+ Run before any visual QA or "done" declaration.
48
52
  help Show this help
49
53
 
50
54
  Common options:
@@ -79,6 +83,9 @@ flags.toolkitRoot = TOOLKIT_ROOT;
79
83
  case 'validate':
80
84
  await validate(flags);
81
85
  break;
86
+ case 'qa':
87
+ await qa(flags);
88
+ break;
82
89
  case undefined:
83
90
  case 'help':
84
91
  case '--help':
package/lib/qa.js ADDED
@@ -0,0 +1,210 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+ import { loadConfig, loadModuleManifest, resolveVariant } from './config-loader.js';
5
+
6
+ /* qa: code-first QA pipeline for a Runwell-managed Shopify theme.
7
+
8
+ Runs in fixed order; each stage is non-fatal so callers see the full
9
+ picture before fixing. Exits 0 with a structured summary; exits 1 only
10
+ if the theme cannot be inspected at all (no manifest, no config).
11
+
12
+ Stages:
13
+ 1. validate-config runwell.config.json shape + module references
14
+ 2. doctor drift between runwell-manifest and synced files
15
+ 3. template-integrity templates/*.json sections referenced in order
16
+ 4. orphan-assets runwell-* files with no matching enabled module
17
+ 5. theme-check shopify CLI theme-check (lint), if available
18
+
19
+ Caller can skip stages with --skip <name,name>.
20
+ */
21
+
22
+ const STAGES = ['validate-config', 'doctor', 'template-integrity', 'orphan-assets', 'theme-check'];
23
+
24
+ export async function qa(flags) {
25
+ const targetDir = path.resolve(flags.target || process.cwd());
26
+ const skip = new Set((flags.skip || '').split(',').map(s => s.trim()).filter(Boolean));
27
+
28
+ const findings = { errors: [], warnings: [], info: [] };
29
+ const log = (level, stage, msg) => {
30
+ findings[level].push({ stage, msg });
31
+ const tag = level === 'errors' ? 'FAIL' : level === 'warnings' ? 'WARN' : 'OK ';
32
+ console.log(` [${tag}] ${stage}: ${msg}`);
33
+ };
34
+
35
+ console.log(`runwell-shopify qa @ ${targetDir}`);
36
+ console.log('');
37
+
38
+ // Stage 1: validate-config
39
+ if (!skip.has('validate-config')) {
40
+ console.log('1/5 validate-config');
41
+ try {
42
+ const { config } = loadConfig(flags.config || './runwell.config.json');
43
+ if (!config.client) log('errors', 'validate-config', 'missing required field "client"');
44
+ if (!config.store) log('warnings', 'validate-config', 'missing field "store" (registry lookups will fail)');
45
+ const enabled = Object.entries(config.modules || {}).filter(([, v]) => v.enabled !== false);
46
+ log('info', 'validate-config', `${enabled.length} modules enabled`);
47
+ for (const [name, entry] of enabled) {
48
+ try {
49
+ const m = loadModuleManifest(flags.toolkitRoot, name);
50
+ if (entry.variant) resolveVariant(m, entry.variant);
51
+ } catch (e) {
52
+ log('errors', 'validate-config', `${name}: ${e.message}`);
53
+ }
54
+ }
55
+ } catch (e) {
56
+ log('errors', 'validate-config', e.message);
57
+ }
58
+ console.log('');
59
+ }
60
+
61
+ // Stage 2: doctor (manifest drift)
62
+ if (!skip.has('doctor')) {
63
+ console.log('2/5 doctor (manifest drift)');
64
+ const manifestPath = path.join(targetDir, 'runwell-manifest.json');
65
+ if (!fs.existsSync(manifestPath)) {
66
+ log('errors', 'doctor', 'no runwell-manifest.json (run "runwell-shopify sync" first)');
67
+ } else {
68
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
69
+ let missing = 0;
70
+ for (const tracked of manifest.files) {
71
+ if (!fs.existsSync(path.join(targetDir, tracked.path))) {
72
+ log('errors', 'doctor', `tracked file missing: ${tracked.path}`);
73
+ missing++;
74
+ }
75
+ }
76
+ if (!missing) log('info', 'doctor', `${manifest.files.length} tracked files all present`);
77
+ log('info', 'doctor', `manifest toolkit version: ${manifest.toolkit_version}`);
78
+ }
79
+ console.log('');
80
+ }
81
+
82
+ // Stage 3: template-integrity (every section in templates/*.json must exist)
83
+ if (!skip.has('template-integrity')) {
84
+ console.log('3/5 template-integrity');
85
+ const tmplDir = path.join(targetDir, 'templates');
86
+ if (fs.existsSync(tmplDir)) {
87
+ const jsons = fs.readdirSync(tmplDir).filter(f => f.endsWith('.json'));
88
+ const sectionsDir = path.join(targetDir, 'sections');
89
+ const knownSections = new Set();
90
+ if (fs.existsSync(sectionsDir)) {
91
+ for (const f of fs.readdirSync(sectionsDir)) {
92
+ if (f.endsWith('.liquid')) knownSections.add(f.replace(/\.liquid$/, ''));
93
+ }
94
+ }
95
+ let badRefs = 0;
96
+ for (const jf of jsons) {
97
+ const tpath = path.join(tmplDir, jf);
98
+ let parsed;
99
+ try { parsed = JSON.parse(fs.readFileSync(tpath, 'utf8')); }
100
+ catch (e) { log('errors', 'template-integrity', `${jf}: invalid JSON: ${e.message}`); continue; }
101
+ const sections = parsed.sections || {};
102
+ const order = parsed.order || Object.keys(sections);
103
+ for (const id of order) {
104
+ if (!sections[id]) {
105
+ log('errors', 'template-integrity', `${jf}: order references unknown section id "${id}"`);
106
+ badRefs++;
107
+ } else {
108
+ const t = sections[id].type;
109
+ // Type can be a built-in (Dawn-shipped, no local file) or a section file we have
110
+ // Only flag if it looks like our convention (no group sections) and isn't found locally and is not a Dawn default name
111
+ if (t && t.startsWith('runwell-') && !knownSections.has(t)) {
112
+ log('errors', 'template-integrity', `${jf}: section "${id}" type "${t}" has no matching sections/${t}.liquid`);
113
+ badRefs++;
114
+ }
115
+ }
116
+ }
117
+ }
118
+ if (!badRefs) log('info', 'template-integrity', `${jsons.length} template files all reference valid sections`);
119
+ } else {
120
+ log('warnings', 'template-integrity', 'no templates/ directory');
121
+ }
122
+ console.log('');
123
+ }
124
+
125
+ // Stage 4: orphan-assets (runwell-* files in theme that aren't synced from any enabled module)
126
+ if (!skip.has('orphan-assets')) {
127
+ console.log('4/5 orphan-assets');
128
+ const manifestPath = path.join(targetDir, 'runwell-manifest.json');
129
+ if (fs.existsSync(manifestPath)) {
130
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
131
+ const tracked = new Set(manifest.files.map(f => f.path));
132
+ const buckets = ['sections', 'snippets', 'assets', 'templates'];
133
+ let orphans = 0;
134
+ for (const b of buckets) {
135
+ const dir = path.join(targetDir, b);
136
+ if (!fs.existsSync(dir)) continue;
137
+ for (const f of fs.readdirSync(dir)) {
138
+ if (!f.startsWith('runwell-')) continue;
139
+ const rel = `${b}/${f}`;
140
+ if (!tracked.has(rel)) {
141
+ log('warnings', 'orphan-assets', `${rel} is runwell-prefixed but not in current manifest (stale sync?)`);
142
+ orphans++;
143
+ }
144
+ }
145
+ }
146
+ if (!orphans) log('info', 'orphan-assets', 'no orphans');
147
+ }
148
+ console.log('');
149
+ }
150
+
151
+ // Stage 5: theme-check via Shopify CLI (parse --output json for accuracy)
152
+ if (!skip.has('theme-check')) {
153
+ console.log('5/5 theme-check');
154
+ await new Promise(resolve => {
155
+ const proc = spawn('shopify', ['theme', 'check', '--path', targetDir, '--output', 'json'], {
156
+ stdio: ['ignore', 'pipe', 'pipe']
157
+ });
158
+ let stdout = '';
159
+ proc.stdout.on('data', d => stdout += d.toString());
160
+ proc.on('error', () => {
161
+ log('warnings', 'theme-check', 'shopify CLI not available; skipping');
162
+ resolve();
163
+ });
164
+ proc.on('exit', () => {
165
+ let parsed;
166
+ try { parsed = JSON.parse(stdout); } catch { parsed = null; }
167
+ if (!Array.isArray(parsed)) {
168
+ log('warnings', 'theme-check', 'could not parse output');
169
+ return resolve();
170
+ }
171
+ const offenses = parsed.flatMap(f => (f.offenses || []).map(o => ({ ...o, path: f.path })));
172
+ const errs = offenses.filter(o => o.severity === 'error' || o.severity === 0);
173
+ const warns = offenses.filter(o => o.severity === 'warning' || o.severity === 1);
174
+ if (errs.length === 0 && warns.length === 0) {
175
+ log('info', 'theme-check', 'clean');
176
+ } else {
177
+ // Per-check rollup so the summary is actionable
178
+ const byCheck = {};
179
+ for (const o of errs) byCheck[o.check] = (byCheck[o.check] || 0) + 1;
180
+ const top = Object.entries(byCheck).sort((a, b) => b[1] - a[1]).slice(0, 5)
181
+ .map(([c, n]) => `${c}×${n}`).join(', ');
182
+ log(errs.length ? 'errors' : 'warnings', 'theme-check',
183
+ `${errs.length} errors, ${warns.length} warnings${top ? ' (' + top + ')' : ''}`);
184
+ // List the file-level offenders so users can jump straight to fixes
185
+ const filesWithErr = [...new Set(errs.map(e => path.relative(targetDir, e.path)))];
186
+ for (const f of filesWithErr.slice(0, 8)) {
187
+ const n = errs.filter(e => path.relative(targetDir, e.path) === f).length;
188
+ log('errors', 'theme-check', ` ${f}: ${n} error(s)`);
189
+ }
190
+ }
191
+ resolve();
192
+ });
193
+ });
194
+ console.log('');
195
+ }
196
+
197
+ // Summary
198
+ console.log('---');
199
+ console.log(`Summary: ${findings.errors.length} errors, ${findings.warnings.length} warnings, ${findings.info.length} info`);
200
+ if (findings.errors.length) {
201
+ console.log('\nErrors (fix before declaring done):');
202
+ findings.errors.forEach(f => console.log(` - [${f.stage}] ${f.msg}`));
203
+ }
204
+ if (findings.warnings.length) {
205
+ console.log('\nWarnings (review):');
206
+ findings.warnings.forEach(f => console.log(` - [${f.stage}] ${f.msg}`));
207
+ }
208
+
209
+ if (findings.errors.length) process.exitCode = 2;
210
+ }
package/lib/sync.js CHANGED
@@ -32,8 +32,19 @@ export async function sync(flags) {
32
32
  // Determine which files to copy: variant files override base files when present
33
33
  const fileGroups = (variant && variant.files) || manifest.files || {};
34
34
 
35
+ // Merge schema defaults under merchant config so {{config.X}} interpolates
36
+ // even when the client did not override every key. Schema lives in
37
+ // module.json config.schema and (for variants) variants.<name>.config_extra.
38
+ const baseSchema = (manifest.config && manifest.config.schema) || {};
39
+ const variantExtra = (variant && variant.config_extra) || {};
40
+ const defaults = {};
41
+ for (const [k, v] of Object.entries({ ...baseSchema, ...variantExtra })) {
42
+ if (v && Object.prototype.hasOwnProperty.call(v, 'default')) defaults[k] = v.default;
43
+ }
44
+ const resolvedConfig = { ...defaults, ...moduleConfig };
45
+
35
46
  const interpolationVars = {
36
- config: moduleConfig,
47
+ config: resolvedConfig,
37
48
  brand: config.brand,
38
49
  client: { name: config.client, store: config.store }
39
50
  };
@@ -62,7 +62,6 @@
62
62
  {%- assign cycle_seconds = section.settings.fomo_cycle_days | times: 86400 -%}
63
63
  {%- assign cycle_position = now_epoch | modulo: cycle_seconds -%}
64
64
  {%- assign remaining_seconds = cycle_seconds | minus: cycle_position -%}
65
- {%- assign remaining_days = remaining_seconds | divided_by: 86400 -%}
66
65
  {%- assign end_epoch = now_epoch | plus: remaining_seconds -%}
67
66
  {%- assign end_date = end_epoch | date: '%B %e' -%}
68
67
 
@@ -29,20 +29,20 @@
29
29
  {%- endif -%}
30
30
 
31
31
  {%- if section.blocks.size > 0 -%}
32
- <div style="overflow-x: auto;">
33
- <table style="width: 100%; border-collapse: collapse; min-width: 520px; font-family: var(--font-body-family); font-size: 0.95rem;">
32
+ <div style="overflow-x: auto; margin: 0 auto; max-width: 880px;">
33
+ <table style="width: 100%; border-collapse: collapse; min-width: 600px; font-family: var(--font-body-family); font-size: 1.05rem;">
34
34
  <thead>
35
35
  <tr>
36
- <th style="text-align: left; padding: 0.9rem 1rem 0.9rem 0; font-family: var(--font-body-family); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.6; vertical-align: bottom; border-bottom: 2px solid currentColor;">
36
+ <th style="text-align: left; width: 55%; padding: 1.1rem 1.2rem 1.1rem 0; font-family: var(--font-body-family); font-size: 0.78rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.55; vertical-align: bottom; border-bottom: 2px solid currentColor;">
37
37
  {{ section.settings.feature_label }}
38
38
  </th>
39
- <th style="text-align: center; padding: 0.9rem 1rem; vertical-align: bottom; border-bottom: 2px solid currentColor;">
40
- <div style="font-family: var(--font-heading-family); font-style: italic; font-weight: 400; font-size: 1.15rem; line-height: 1.2;">
39
+ <th style="text-align: center; width: 22.5%; padding: 1.1rem 1rem; vertical-align: bottom; border-bottom: 2px solid currentColor;">
40
+ <div style="font-family: var(--font-heading-family); font-style: italic; font-weight: 400; font-size: 1.4rem; line-height: 1.2;">
41
41
  {{ section.settings.brand_name }}
42
42
  </div>
43
43
  </th>
44
- <th style="text-align: center; padding: 0.9rem 1rem; vertical-align: bottom; border-bottom: 2px solid currentColor;">
45
- <div style="font-family: var(--font-heading-family); font-style: italic; font-weight: 400; font-size: 1.15rem; line-height: 1.2; opacity: 0.7;">
44
+ <th style="text-align: center; width: 22.5%; padding: 1.1rem 1rem; vertical-align: bottom; border-bottom: 2px solid currentColor;">
45
+ <div style="font-family: var(--font-heading-family); font-style: italic; font-weight: 400; font-size: 1.4rem; line-height: 1.2; opacity: 0.7;">
46
46
  {{ section.settings.competitor_name }}
47
47
  </div>
48
48
  </th>
@@ -51,21 +51,21 @@
51
51
  <tbody>
52
52
  {%- for block in section.blocks -%}
53
53
  <tr {{ block.shopify_attributes }}>
54
- <td style="padding: 0.9rem 1rem 0.9rem 0; vertical-align: top; border-bottom: 1px solid rgba(0,0,0,0.08); font-weight: 600;">
54
+ <td style="padding: 1.05rem 1.2rem 1.05rem 0; vertical-align: middle; border-bottom: 1px solid rgba(0,0,0,0.08); font-weight: 600; line-height: 1.4;">
55
55
  {{ block.settings.feature }}
56
56
  </td>
57
- <td style="padding: 0.9rem 1rem; vertical-align: top; border-bottom: 1px solid rgba(0,0,0,0.08); text-align: center;">
57
+ <td style="padding: 1.05rem 1rem; vertical-align: middle; border-bottom: 1px solid rgba(0,0,0,0.08); text-align: center;">
58
58
  {%- if block.settings.ours == 'yes' -%}
59
- <span style="display: inline-block; min-width: 1.6rem; font-weight: 700; color: var(--runwell-rain-forrest, currentColor);">&check;</span>
59
+ <span style="display: inline-block; min-width: 1.6rem; font-size: 1.3rem; font-weight: 700; color: var(--runwell-rain-forrest, currentColor);">&check;</span>
60
60
  {%- elsif block.settings.ours == 'no' -%}
61
61
  <span style="display: inline-block; min-width: 1.6rem; opacity: 0.4;">&times;</span>
62
62
  {%- else -%}
63
63
  <span style="opacity: 0.92;">{{ block.settings.ours_text }}</span>
64
64
  {%- endif -%}
65
65
  </td>
66
- <td style="padding: 0.9rem 1rem; vertical-align: top; border-bottom: 1px solid rgba(0,0,0,0.08); text-align: center;">
66
+ <td style="padding: 1.05rem 1rem; vertical-align: middle; border-bottom: 1px solid rgba(0,0,0,0.08); text-align: center;">
67
67
  {%- if block.settings.theirs == 'yes' -%}
68
- <span style="display: inline-block; min-width: 1.6rem; font-weight: 700; opacity: 0.6;">&check;</span>
68
+ <span style="display: inline-block; min-width: 1.6rem; font-size: 1.3rem; font-weight: 700; opacity: 0.55;">&check;</span>
69
69
  {%- elsif block.settings.theirs == 'no' -%}
70
70
  <span style="display: inline-block; min-width: 1.6rem; opacity: 0.4;">&times;</span>
71
71
  {%- else -%}
@@ -16,7 +16,6 @@
16
16
  {%- for r in reviews_json -%}
17
17
  {%- assign sum = sum | plus: r.rating -%}
18
18
  {%- endfor -%}
19
- {%- assign avg = sum | divided_by: reviews_json.size | times: 1.0 -%}
20
19
  {%- assign avg_int = sum | divided_by: reviews_json.size -%}
21
20
  <div class="runwell-reviews__summary">
22
21
  <span class="runwell-reviews__stars" aria-label="{{ avg_int }} out of 5">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runwell/shopify-toolkit",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Reusable Shopify theme modules from Runwell. Replaces typically app-driven features (reviews, wishlist, urgency, FAQ, post-purchase upsell, exit popups, free-ship progress, sticky ATC, testimonials, badges, bundles) with native Liquid + JS + CSS that ship across multiple client themes via a config-driven sync CLI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",