@runwell/shopify-toolkit 0.1.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.
Files changed (76) hide show
  1. package/README.md +87 -0
  2. package/bin/runwell-shopify +98 -0
  3. package/lib/add.js +66 -0
  4. package/lib/config-loader.js +47 -0
  5. package/lib/doctor.js +66 -0
  6. package/lib/list.js +31 -0
  7. package/lib/remove.js +37 -0
  8. package/lib/sync.js +107 -0
  9. package/lib/template.js +57 -0
  10. package/lib/validate.js +23 -0
  11. package/modules/comparison-table/README.md +17 -0
  12. package/modules/comparison-table/module.json +50 -0
  13. package/modules/comparison-table/sections/runwell-comparison-table.liquid +157 -0
  14. package/modules/delivery-estimate/README.md +17 -0
  15. package/modules/delivery-estimate/module.json +14 -0
  16. package/modules/delivery-estimate/snippets/runwell-delivery-estimate.liquid +39 -0
  17. package/modules/editorial-block/README.md +17 -0
  18. package/modules/editorial-block/module.json +40 -0
  19. package/modules/editorial-block/sections/runwell-editorial-block.liquid +155 -0
  20. package/modules/editorial-hero/README.md +17 -0
  21. package/modules/editorial-hero/module.json +61 -0
  22. package/modules/editorial-hero/sections/runwell-video-hero.liquid +151 -0
  23. package/modules/exit-intent/README.md +18 -0
  24. package/modules/exit-intent/assets/runwell-exit-intent.js +54 -0
  25. package/modules/exit-intent/module.json +33 -0
  26. package/modules/exit-intent/sections/runwell-exit-intent.liquid +48 -0
  27. package/modules/faq/README.md +17 -0
  28. package/modules/faq/module.json +35 -0
  29. package/modules/faq/sections/runwell-faq.liquid +66 -0
  30. package/modules/how-it-works/README.md +17 -0
  31. package/modules/how-it-works/module.json +57 -0
  32. package/modules/how-it-works/sections/runwell-how-it-works.liquid +99 -0
  33. package/modules/inventory-urgency/README.md +17 -0
  34. package/modules/inventory-urgency/module.json +14 -0
  35. package/modules/inventory-urgency/snippets/runwell-inventory-urgency.liquid +19 -0
  36. package/modules/pdp-ingredients/README.md +17 -0
  37. package/modules/pdp-ingredients/module.json +40 -0
  38. package/modules/pdp-ingredients/sections/runwell-ingredients.liquid +139 -0
  39. package/modules/pdp-journal-link/README.md +17 -0
  40. package/modules/pdp-journal-link/module.json +53 -0
  41. package/modules/pdp-journal-link/sections/runwell-pdp-journal.liquid +124 -0
  42. package/modules/pdp-trust-checks/README.md +17 -0
  43. package/modules/pdp-trust-checks/module.json +49 -0
  44. package/modules/pdp-trust-checks/sections/runwell-pdp-trust.liquid +141 -0
  45. package/modules/post-purchase-upsell/README.md +52 -0
  46. package/modules/post-purchase-upsell/admin/discount-setup.md +25 -0
  47. package/modules/post-purchase-upsell/admin/order-status-paste.html +31 -0
  48. package/modules/post-purchase-upsell/assets/runwell-thank-you.css +119 -0
  49. package/modules/post-purchase-upsell/assets/runwell-thank-you.js +162 -0
  50. package/modules/post-purchase-upsell/module.json +44 -0
  51. package/modules/press-bar/README.md +17 -0
  52. package/modules/press-bar/module.json +30 -0
  53. package/modules/press-bar/sections/runwell-press-bar.liquid +119 -0
  54. package/modules/recently-viewed/README.md +18 -0
  55. package/modules/recently-viewed/assets/runwell-recently-viewed.js +57 -0
  56. package/modules/recently-viewed/module.json +33 -0
  57. package/modules/recently-viewed/sections/runwell-recently-viewed.liquid +38 -0
  58. package/modules/reviews/README.md +17 -0
  59. package/modules/reviews/module.json +20 -0
  60. package/modules/reviews/sections/runwell-pdp-reviews.liquid +93 -0
  61. package/modules/risk-reversal/README.md +17 -0
  62. package/modules/risk-reversal/module.json +49 -0
  63. package/modules/risk-reversal/sections/runwell-risk-reversal.liquid +94 -0
  64. package/modules/shipping-bar/README.md +17 -0
  65. package/modules/shipping-bar/module.json +45 -0
  66. package/modules/shipping-bar/sections/runwell-shipping-bar.liquid +95 -0
  67. package/modules/sticky-atc/README.md +17 -0
  68. package/modules/sticky-atc/module.json +14 -0
  69. package/modules/sticky-atc/sections/runwell-pdp-sticky.liquid +78 -0
  70. package/modules/testimonials/README.md +17 -0
  71. package/modules/testimonials/module.json +35 -0
  72. package/modules/testimonials/sections/runwell-testimonials.liquid +87 -0
  73. package/modules/trust-badges/README.md +17 -0
  74. package/modules/trust-badges/module.json +25 -0
  75. package/modules/trust-badges/sections/runwell-trust-badges.liquid +93 -0
  76. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # @runwell/shopify-toolkit
2
+
3
+ Reusable Shopify theme modules from Runwell. Each module replaces a typically app-driven e-commerce feature with native Liquid + JS + CSS so the client store ships with zero apps and zero monthly app fees, while every client gets the same polished baseline.
4
+
5
+ Built initially while shipping the Lushi storefront. Designed to apply across every Shopify client we onboard (Lushi, Lusha, Books and Bourbon, Receptia, and forward).
6
+
7
+ ## What is in here
8
+
9
+ A collection of modules under `modules/`. Each module is a self-contained folder with everything Shopify needs (sections, snippets, JS, CSS, admin paste blocks) and a `module.json` describing config schema, file layout, and any merchant admin steps.
10
+
11
+ A sync CLI (`runwell-shopify`) that reads a per-client `runwell.config.json` and copies the right module files (with the right variant and brand vars) into the client's theme directory, ready to push.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -D @runwell/shopify-toolkit
17
+ ```
18
+
19
+ Then in the client theme repo create a `runwell.config.json` (see `examples/`) and run:
20
+
21
+ ```bash
22
+ npx runwell-shopify sync
23
+ ```
24
+
25
+ The CLI writes the module files into `assets/`, `sections/`, `snippets/`, and `templates/` of the target theme. A `runwell-manifest.json` is written so we can track which files the toolkit owns and detect drift.
26
+
27
+ ## Common commands
28
+
29
+ ```bash
30
+ runwell-shopify sync # apply runwell.config.json to current theme
31
+ runwell-shopify list # list available + installed modules
32
+ runwell-shopify add <module> # enable a module in runwell.config.json
33
+ runwell-shopify remove <module> # disable + clean files
34
+ runwell-shopify doctor # detect drift between manifest and theme
35
+ runwell-shopify validate # run @shopify/dev-mcp validators on synced files
36
+ ```
37
+
38
+ ## Module list (current)
39
+
40
+ The full list lives in `modules/INDEX.md` (auto-generated from each module's `module.json`).
41
+
42
+ Categories at a glance:
43
+
44
+ - **Conversion**: post-purchase-upsell, exit-intent, sticky-atc, inventory-urgency, delivery-estimate, cart-cross-sell, cart-freeship-progress, cart-usps
45
+ - **Social proof**: testimonials, reviews, press-bar, product-badges
46
+ - **Storefront sections**: editorial-hero, editorial-block, how-it-works, trust-badges, risk-reversal, comparison-table, shipping-bar, faq
47
+ - **PDP**: pdp-trust-checks, pdp-journal-link, pdp-ingredients, recently-viewed
48
+ - **Catalog and pricing**: bundle-builder, quantity-breaks, gift-with-purchase, subscriptions
49
+ - **Customer**: wishlist, quick-view, loyalty-tiers
50
+
51
+ ## Variants
52
+
53
+ Some modules ship multiple visual treatments. The client config picks one. See [ARCHITECTURE.md](./ARCHITECTURE.md) section 2.0.5 for the full pattern.
54
+
55
+ For example, `testimonials` ships:
56
+ - `grid-quotes` (default; editorial brands)
57
+ - `carousel-ugc` (DTC, UGC-heavy brands)
58
+ - `single-quote-hero` (high-end, low-density)
59
+ - `masonry`
60
+
61
+ The client config picks one:
62
+
63
+ ```json
64
+ "testimonials": {
65
+ "enabled": true,
66
+ "variant": "carousel-ugc",
67
+ "config": { "heading": "From the feed.", "autoplay_seconds": 5 }
68
+ }
69
+ ```
70
+
71
+ ## Architecture
72
+
73
+ Full design lives in [ARCHITECTURE.md](./ARCHITECTURE.md). Source of truth is `claude-PM/projects/shopify-agent/runwell-shopify-toolkit-architecture.md`.
74
+
75
+ ## Integration with shopify-agent
76
+
77
+ This toolkit is the codified output of the Runwell shopify-agent skills. When the agent is asked to add a feature to a client store, it checks this toolkit first before generating one-off Liquid. See `claude-PM/foundation/skills/shopify-storefront/` and `claude-PM/foundation/skills/shopify-cro/` for the routing logic.
78
+
79
+ ## Distribution
80
+
81
+ - Published as `@runwell/shopify-toolkit` on npmjs (private until v1.0)
82
+ - Authentication: NPM token configured under `runwellsystems` org. Token in `~/.npmrc` and key-manager
83
+ - Private clients install via npm; can also be vendored as a git submodule for offline use
84
+
85
+ ## License
86
+
87
+ UNLICENSED. Internal Runwell tooling.
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+ import { sync } from '../lib/sync.js';
5
+ import { list } from '../lib/list.js';
6
+ import { add } from '../lib/add.js';
7
+ import { remove } from '../lib/remove.js';
8
+ import { doctor } from '../lib/doctor.js';
9
+ import { validate } from '../lib/validate.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ const TOOLKIT_ROOT = path.resolve(__dirname, '..');
14
+
15
+ const argv = process.argv.slice(2);
16
+ const cmd = argv[0];
17
+
18
+ function parseFlags(args) {
19
+ const flags = { positional: [] };
20
+ for (let i = 0; i < args.length; i++) {
21
+ const a = args[i];
22
+ if (a.startsWith('--')) {
23
+ const key = a.slice(2);
24
+ const next = args[i + 1];
25
+ if (!next || next.startsWith('--')) {
26
+ flags[key] = true;
27
+ } else {
28
+ flags[key] = next;
29
+ i++;
30
+ }
31
+ } else {
32
+ flags.positional.push(a);
33
+ }
34
+ }
35
+ return flags;
36
+ }
37
+
38
+ function help() {
39
+ console.log(`runwell-shopify <command> [options]
40
+
41
+ Commands:
42
+ sync Apply runwell.config.json to the target theme
43
+ list List available + installed modules
44
+ add <module> Enable a module in runwell.config.json
45
+ remove <module> Disable + clean a module's files
46
+ doctor Detect drift between manifest and theme
47
+ validate Run @shopify/dev-mcp validators on synced files
48
+ help Show this help
49
+
50
+ Common options:
51
+ --config <path> Path to runwell.config.json (default ./runwell.config.json)
52
+ --target <path> Target theme dir (default cwd)
53
+ --dry-run Show what would change without writing
54
+ --verbose Verbose logging
55
+ `);
56
+ }
57
+
58
+ const flags = parseFlags(argv.slice(1));
59
+ flags.toolkitRoot = TOOLKIT_ROOT;
60
+
61
+ (async () => {
62
+ try {
63
+ switch (cmd) {
64
+ case 'sync':
65
+ await sync(flags);
66
+ break;
67
+ case 'list':
68
+ await list(flags);
69
+ break;
70
+ case 'add':
71
+ await add(flags);
72
+ break;
73
+ case 'remove':
74
+ await remove(flags);
75
+ break;
76
+ case 'doctor':
77
+ await doctor(flags);
78
+ break;
79
+ case 'validate':
80
+ await validate(flags);
81
+ break;
82
+ case undefined:
83
+ case 'help':
84
+ case '--help':
85
+ case '-h':
86
+ help();
87
+ break;
88
+ default:
89
+ console.error('Unknown command: ' + cmd);
90
+ help();
91
+ process.exit(1);
92
+ }
93
+ } catch (err) {
94
+ console.error('Error:', err.message);
95
+ if (flags.verbose) console.error(err.stack);
96
+ process.exit(1);
97
+ }
98
+ })();
package/lib/add.js ADDED
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadModuleManifest } from './config-loader.js';
4
+
5
+ export async function add(flags) {
6
+ const moduleName = flags.positional[0];
7
+ if (!moduleName) throw new Error('Usage: runwell-shopify add <module-name>');
8
+
9
+ const configPath = path.resolve(flags.config || './runwell.config.json');
10
+ let config;
11
+ if (fs.existsSync(configPath)) {
12
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
13
+ } else {
14
+ config = bootstrapConfig();
15
+ console.log(`Created new config at ${configPath}`);
16
+ }
17
+
18
+ const manifest = loadModuleManifest(flags.toolkitRoot, moduleName);
19
+ config.modules = config.modules || {};
20
+ if (config.modules[moduleName] && config.modules[moduleName].enabled) {
21
+ console.log(`${moduleName} is already enabled in config.`);
22
+ return;
23
+ }
24
+
25
+ const defaults = {};
26
+ if (manifest.config && manifest.config.schema) {
27
+ for (const [key, spec] of Object.entries(manifest.config.schema)) {
28
+ if (spec.default !== undefined) defaults[key] = spec.default;
29
+ }
30
+ }
31
+
32
+ config.modules[moduleName] = {
33
+ enabled: true,
34
+ config: defaults
35
+ };
36
+ if (manifest.default_variant) {
37
+ config.modules[moduleName].variant = manifest.default_variant;
38
+ }
39
+
40
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
41
+ console.log(`Added ${moduleName} to ${path.relative(process.cwd(), configPath)}`);
42
+ if (manifest.admin_steps && manifest.admin_steps.length) {
43
+ console.log(`\nAdmin steps required for this module:`);
44
+ manifest.admin_steps.forEach((s, i) => {
45
+ console.log(` ${i + 1}. ${s.label}`);
46
+ if (s.summary) console.log(` ${s.summary}`);
47
+ });
48
+ }
49
+ console.log(`\nNext: runwell-shopify sync`);
50
+ }
51
+
52
+ function bootstrapConfig() {
53
+ return {
54
+ $schema: 'https://github.com/runwellsystems/runwell-shopify-toolkit/raw/main/schemas/runwell.config.schema.json',
55
+ client: 'unknown',
56
+ store: 'unknown.myshopify.com',
57
+ theme_path: '.',
58
+ toolkit_version: '^0.1.0',
59
+ brand: {
60
+ primary: '#000000',
61
+ accent: '#000000',
62
+ currency: 'USD'
63
+ },
64
+ modules: {}
65
+ };
66
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function loadConfig(configPath) {
5
+ const abs = path.resolve(configPath);
6
+ if (!fs.existsSync(abs)) {
7
+ throw new Error(`Config not found at ${abs}. Run "runwell-shopify add <module>" to bootstrap one.`);
8
+ }
9
+ const raw = fs.readFileSync(abs, 'utf8');
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(raw);
13
+ } catch (e) {
14
+ throw new Error(`Failed to parse ${abs}: ${e.message}`);
15
+ }
16
+ if (!parsed.client) throw new Error('Config missing required field "client"');
17
+ if (!parsed.brand) parsed.brand = {};
18
+ if (!parsed.modules) parsed.modules = {};
19
+ return { config: parsed, path: abs };
20
+ }
21
+
22
+ export function loadModuleManifest(toolkitRoot, moduleName) {
23
+ const manifestPath = path.join(toolkitRoot, 'modules', moduleName, 'module.json');
24
+ if (!fs.existsSync(manifestPath)) {
25
+ throw new Error(`Module "${moduleName}" not found at ${manifestPath}`);
26
+ }
27
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
28
+ }
29
+
30
+ export function listAvailableModules(toolkitRoot) {
31
+ const modulesDir = path.join(toolkitRoot, 'modules');
32
+ if (!fs.existsSync(modulesDir)) return [];
33
+ return fs.readdirSync(modulesDir)
34
+ .filter(name => !name.startsWith('_') && !name.startsWith('.'))
35
+ .filter(name => fs.existsSync(path.join(modulesDir, name, 'module.json')));
36
+ }
37
+
38
+ export function resolveVariant(manifest, requested) {
39
+ if (!manifest.variants) return null;
40
+ const variantName = requested || manifest.default_variant;
41
+ if (!variantName) return null;
42
+ const variant = manifest.variants[variantName];
43
+ if (!variant) {
44
+ throw new Error(`Variant "${variantName}" not found in module ${manifest.name}. Available: ${Object.keys(manifest.variants).join(', ')}`);
45
+ }
46
+ return { name: variantName, ...variant };
47
+ }
package/lib/doctor.js ADDED
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { loadConfig, loadModuleManifest, resolveVariant } from './config-loader.js';
5
+ import { interpolate } from './template.js';
6
+
7
+ export async function doctor(flags) {
8
+ const targetDir = path.resolve(flags.target || process.cwd());
9
+ const manifestPath = path.join(targetDir, 'runwell-manifest.json');
10
+ if (!fs.existsSync(manifestPath)) {
11
+ console.log('No runwell-manifest.json found. Run "runwell-shopify sync" first.');
12
+ return;
13
+ }
14
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
15
+ const { config } = loadConfig(flags.config || './runwell.config.json');
16
+
17
+ let cleanFiles = 0;
18
+ let driftFiles = 0;
19
+ let missingFiles = 0;
20
+
21
+ for (const tracked of manifest.files) {
22
+ const targetPath = path.join(targetDir, tracked.path);
23
+ if (!fs.existsSync(targetPath)) {
24
+ console.log(` [missing] ${tracked.path}`);
25
+ missingFiles++;
26
+ continue;
27
+ }
28
+
29
+ const moduleManifest = loadModuleManifest(flags.toolkitRoot, tracked.module);
30
+ const cfgEntry = config.modules[tracked.module];
31
+ const variant = resolveVariant(moduleManifest, cfgEntry && cfgEntry.variant);
32
+ const interpolationVars = {
33
+ config: (cfgEntry && cfgEntry.config) || {},
34
+ brand: config.brand,
35
+ client: { name: config.client, store: config.store }
36
+ };
37
+
38
+ // Resolve original source path
39
+ const filename = path.basename(tracked.path);
40
+ const fileGroups = (variant && variant.files) || moduleManifest.files || {};
41
+ const bucketFiles = fileGroups[tracked.bucket] || [];
42
+ const sourceRel = bucketFiles.find(s => path.basename(s) === filename);
43
+ if (!sourceRel) continue;
44
+ const sourcePath = path.join(flags.toolkitRoot, 'modules', tracked.module, sourceRel);
45
+ if (!fs.existsSync(sourcePath)) continue;
46
+
47
+ const expected = interpolate(fs.readFileSync(sourcePath, 'utf8'), interpolationVars);
48
+ const actual = fs.readFileSync(targetPath, 'utf8');
49
+ if (hash(expected) === hash(actual)) {
50
+ cleanFiles++;
51
+ } else {
52
+ console.log(` [drift] ${tracked.path}`);
53
+ driftFiles++;
54
+ }
55
+ }
56
+
57
+ console.log(`\n${cleanFiles} clean, ${driftFiles} drifted, ${missingFiles} missing.`);
58
+ if (driftFiles > 0) {
59
+ console.log('Run "runwell-shopify sync" to overwrite drifted files.');
60
+ console.log('If the drift is intentional, move the changes into module config or a custom client section.');
61
+ }
62
+ }
63
+
64
+ function hash(str) {
65
+ return crypto.createHash('sha256').update(str).digest('hex');
66
+ }
package/lib/list.js ADDED
@@ -0,0 +1,31 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { listAvailableModules, loadModuleManifest, loadConfig } from './config-loader.js';
4
+
5
+ export async function list(flags) {
6
+ const available = listAvailableModules(flags.toolkitRoot);
7
+
8
+ let installed = new Set();
9
+ try {
10
+ const { config } = loadConfig(flags.config || './runwell.config.json');
11
+ installed = new Set(Object.keys(config.modules || {}).filter(m => config.modules[m].enabled !== false));
12
+ } catch {
13
+ // no config in cwd; show available only
14
+ }
15
+
16
+ console.log('Runwell Shopify Toolkit modules:\n');
17
+ console.log(' Status Name Category Variants');
18
+ console.log(' ------- ---------------------------- -------------- --------------');
19
+
20
+ for (const name of available.sort()) {
21
+ let manifest;
22
+ try { manifest = loadModuleManifest(flags.toolkitRoot, name); }
23
+ catch { continue; }
24
+ const status = installed.has(name) ? '[on] ' : '[ ] ';
25
+ const cat = (manifest.category || '').padEnd(14);
26
+ const variants = manifest.variants ? Object.keys(manifest.variants).join(', ') : '(no variants)';
27
+ console.log(` ${status} ${name.padEnd(28)} ${cat} ${variants}`);
28
+ }
29
+
30
+ console.log(`\nTotal: ${available.length} modules. Installed in current config: ${installed.size}.`);
31
+ }
package/lib/remove.js ADDED
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export async function remove(flags) {
5
+ const moduleName = flags.positional[0];
6
+ if (!moduleName) throw new Error('Usage: runwell-shopify remove <module-name>');
7
+
8
+ const configPath = path.resolve(flags.config || './runwell.config.json');
9
+ const targetDir = path.resolve(flags.target || process.cwd());
10
+ if (!fs.existsSync(configPath)) throw new Error(`No runwell.config.json at ${configPath}`);
11
+
12
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
13
+ if (!config.modules || !config.modules[moduleName]) {
14
+ console.log(`${moduleName} not in config; nothing to remove.`);
15
+ return;
16
+ }
17
+
18
+ // Read manifest to know which files this module owns
19
+ const manifestPath = path.join(targetDir, 'runwell-manifest.json');
20
+ if (fs.existsSync(manifestPath)) {
21
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
22
+ const owned = manifest.files.filter(f => f.module === moduleName);
23
+ for (const f of owned) {
24
+ const abs = path.join(targetDir, f.path);
25
+ if (fs.existsSync(abs)) {
26
+ fs.unlinkSync(abs);
27
+ console.log(` - ${f.path}`);
28
+ }
29
+ }
30
+ manifest.files = manifest.files.filter(f => f.module !== moduleName);
31
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
32
+ }
33
+
34
+ config.modules[moduleName].enabled = false;
35
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
36
+ console.log(`Disabled ${moduleName}.`);
37
+ }
package/lib/sync.js ADDED
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadConfig, loadModuleManifest, listAvailableModules, resolveVariant } from './config-loader.js';
4
+ import { interpolate } from './template.js';
5
+
6
+ export async function sync(flags) {
7
+ const configPath = flags.config || './runwell.config.json';
8
+ const targetDir = path.resolve(flags.target || process.cwd());
9
+ const dryRun = !!flags['dry-run'] || !!flags.dryRun;
10
+ const verbose = !!flags.verbose;
11
+
12
+ const { config, path: configFullPath } = loadConfig(configPath);
13
+ console.log(`Sync target: ${targetDir}`);
14
+ console.log(`Config: ${configFullPath}`);
15
+ console.log(`Client: ${config.client}`);
16
+ if (dryRun) console.log('Dry run: no files will be written.');
17
+
18
+ const writtenFiles = [];
19
+ const removedFiles = [];
20
+
21
+ const moduleNames = Object.keys(config.modules);
22
+ for (const moduleName of moduleNames) {
23
+ const cfgEntry = config.modules[moduleName];
24
+ if (cfgEntry.enabled === false) {
25
+ if (verbose) console.log(`[skip] ${moduleName} (disabled)`);
26
+ continue;
27
+ }
28
+ const manifest = loadModuleManifest(flags.toolkitRoot, moduleName);
29
+ const variant = resolveVariant(manifest, cfgEntry.variant);
30
+ const moduleConfig = cfgEntry.config || {};
31
+
32
+ // Determine which files to copy: variant files override base files when present
33
+ const fileGroups = (variant && variant.files) || manifest.files || {};
34
+
35
+ const interpolationVars = {
36
+ config: moduleConfig,
37
+ brand: config.brand,
38
+ client: { name: config.client, store: config.store }
39
+ };
40
+
41
+ console.log(`\n[${moduleName}${variant ? ' / ' + variant.name : ''}]`);
42
+
43
+ for (const [bucket, fileList] of Object.entries(fileGroups)) {
44
+ if (!Array.isArray(fileList)) continue;
45
+ const targetSubdir = bucketToTargetDir(bucket, targetDir);
46
+ if (!targetSubdir) continue;
47
+ for (const relSrc of fileList) {
48
+ const sourcePath = path.join(flags.toolkitRoot, 'modules', moduleName, relSrc);
49
+ if (!fs.existsSync(sourcePath)) {
50
+ console.warn(` [warn] missing source: ${sourcePath}`);
51
+ continue;
52
+ }
53
+ const filename = path.basename(relSrc);
54
+ const targetPath = path.join(targetSubdir, filename);
55
+ const isText = isTextFile(filename);
56
+ let content = fs.readFileSync(sourcePath, isText ? 'utf8' : null);
57
+ if (isText) content = interpolate(content, interpolationVars);
58
+
59
+ if (!dryRun) {
60
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
61
+ fs.writeFileSync(targetPath, content);
62
+ }
63
+ console.log(` + ${path.relative(targetDir, targetPath)}`);
64
+ writtenFiles.push({ module: moduleName, target: targetPath, bucket });
65
+ }
66
+ }
67
+ }
68
+
69
+ // Manifest
70
+ const manifestPath = path.join(targetDir, 'runwell-manifest.json');
71
+ const manifestData = {
72
+ generated_at: new Date().toISOString(),
73
+ toolkit_version: getToolkitVersion(flags.toolkitRoot),
74
+ client: config.client,
75
+ files: writtenFiles.map(f => ({
76
+ module: f.module,
77
+ bucket: f.bucket,
78
+ path: path.relative(targetDir, f.target)
79
+ }))
80
+ };
81
+ if (!dryRun) fs.writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2));
82
+ console.log(`\nManifest: ${path.relative(targetDir, manifestPath)}`);
83
+ console.log(`Total files: ${writtenFiles.length}`);
84
+ }
85
+
86
+ function bucketToTargetDir(bucket, targetDir) {
87
+ switch (bucket) {
88
+ case 'assets': return path.join(targetDir, 'assets');
89
+ case 'snippets': return path.join(targetDir, 'snippets');
90
+ case 'sections': return path.join(targetDir, 'sections');
91
+ case 'templates': return path.join(targetDir, 'templates');
92
+ case 'admin_blocks': return path.join(targetDir, 'runwell-admin');
93
+ default: return null;
94
+ }
95
+ }
96
+
97
+ function isTextFile(filename) {
98
+ const ext = path.extname(filename).toLowerCase();
99
+ return ['.liquid', '.js', '.css', '.json', '.html', '.svg', '.md', '.txt'].includes(ext);
100
+ }
101
+
102
+ function getToolkitVersion(toolkitRoot) {
103
+ try {
104
+ const pkg = JSON.parse(fs.readFileSync(path.join(toolkitRoot, 'package.json'), 'utf8'));
105
+ return pkg.version;
106
+ } catch { return 'unknown'; }
107
+ }
@@ -0,0 +1,57 @@
1
+ /* Template interpolation for Runwell Shopify Toolkit module files.
2
+
3
+ Modules use {{config.X}} and {{brand.Y}} placeholders to inject
4
+ per-client values. Liquid placeholders that should pass through to
5
+ Shopify ({{ product.title }}) are escaped using a sentinel pattern:
6
+
7
+ In source files: %LIQUID%{{ product.title }}%LIQUID_END%
8
+ After interpolate: {{ product.title }}
9
+
10
+ This keeps Shopify Liquid intact through the toolkit's templating
11
+ step, regardless of how aggressive the placeholder resolver is.
12
+ */
13
+
14
+ const LIQUID_OPEN = '%LIQUID%';
15
+ const LIQUID_CLOSE = '%LIQUID_END%';
16
+
17
+ export function interpolate(source, vars) {
18
+ if (typeof source !== 'string') return source;
19
+
20
+ // First pass: protect Liquid pass-through using sentinels
21
+ let working = source;
22
+
23
+ // Second pass: substitute {{config.X}} and {{brand.Y}} and {{client.Z}}
24
+ working = working.replace(/\{\{\s*([a-zA-Z][a-zA-Z0-9_.]*)\s*\}\}/g, (match, key) => {
25
+ // If the key starts with a Liquid keyword, leave it alone (it is Shopify Liquid)
26
+ const liquidKeywords = new Set([
27
+ 'product', 'collection', 'cart', 'customer', 'shop', 'request',
28
+ 'template', 'page', 'blog', 'article', 'section', 'block',
29
+ 'forloop', 'tablerow', 'paginate', 'comment', 'paginate',
30
+ 'settings', 'routes', 'localization', 'order', 'line_item',
31
+ 'address', 'recommendations', 'predictive_search', 'theme',
32
+ 'all_products', 'collections', 'pages', 'articles', 'blogs',
33
+ 'images', 'image', 'media', 'video', 'metafields'
34
+ ]);
35
+ const root = key.split('.')[0];
36
+ if (liquidKeywords.has(root)) return match;
37
+
38
+ // Resolve from vars (config.foo, brand.bar, client.baz)
39
+ const parts = key.split('.');
40
+ let val = vars;
41
+ for (const p of parts) {
42
+ if (val == null) return match;
43
+ val = val[p];
44
+ }
45
+ if (val == null) return match;
46
+ return String(val);
47
+ });
48
+
49
+ // Third pass: convert sentinels back to Liquid braces
50
+ working = working.split(LIQUID_OPEN).join('{{').split(LIQUID_CLOSE).join('}}');
51
+
52
+ return working;
53
+ }
54
+
55
+ export function interpolateFile(content, vars) {
56
+ return interpolate(content, vars);
57
+ }
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawn } from 'node:child_process';
4
+
5
+ /* validate: runs Shopify theme-check via shopify CLI on the target theme.
6
+ Falls back to a simple Liquid syntax check if the CLI is not available.
7
+ */
8
+ export async function validate(flags) {
9
+ const targetDir = path.resolve(flags.target || process.cwd());
10
+
11
+ return new Promise((resolve, reject) => {
12
+ const proc = spawn('shopify', ['theme', 'check', '--path', targetDir], { stdio: 'inherit' });
13
+ proc.on('error', err => {
14
+ console.log('shopify CLI not available; skipping deep validation.');
15
+ console.log('Install Shopify CLI to enable theme-check: https://shopify.dev/docs/themes/tools/cli');
16
+ resolve();
17
+ });
18
+ proc.on('exit', code => {
19
+ if (code !== 0 && code != null) reject(new Error(`theme check exited ${code}`));
20
+ else resolve();
21
+ });
22
+ });
23
+ }
@@ -0,0 +1,17 @@
1
+ # comparison-table
2
+
3
+ Lushi comparison table. Migrated from Lushi (`sections/lushi-comparison-table.liquid`) into the Runwell Shopify Toolkit.
4
+
5
+ Category: `pdp`
6
+
7
+ ## Files
8
+
9
+ - `sections/runwell-comparison-table.liquid`
10
+
11
+ ## Config
12
+
13
+ See `module.json` config schema. Defaults match the original Lushi defaults; override per client in `runwell.config.json`.
14
+
15
+ ## Lineage
16
+
17
+ This module was extracted from the Lushi build (Capital V) and generalised. The original lives at `lushi-shopify/sections/lushi-comparison-table.liquid`. The toolkit version uses `runwell-` class prefixes and pulls brand vars from the client config.