@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.
- package/README.md +87 -0
- package/bin/runwell-shopify +98 -0
- package/lib/add.js +66 -0
- package/lib/config-loader.js +47 -0
- package/lib/doctor.js +66 -0
- package/lib/list.js +31 -0
- package/lib/remove.js +37 -0
- package/lib/sync.js +107 -0
- package/lib/template.js +57 -0
- package/lib/validate.js +23 -0
- package/modules/comparison-table/README.md +17 -0
- package/modules/comparison-table/module.json +50 -0
- package/modules/comparison-table/sections/runwell-comparison-table.liquid +157 -0
- package/modules/delivery-estimate/README.md +17 -0
- package/modules/delivery-estimate/module.json +14 -0
- package/modules/delivery-estimate/snippets/runwell-delivery-estimate.liquid +39 -0
- package/modules/editorial-block/README.md +17 -0
- package/modules/editorial-block/module.json +40 -0
- package/modules/editorial-block/sections/runwell-editorial-block.liquid +155 -0
- package/modules/editorial-hero/README.md +17 -0
- package/modules/editorial-hero/module.json +61 -0
- package/modules/editorial-hero/sections/runwell-video-hero.liquid +151 -0
- package/modules/exit-intent/README.md +18 -0
- package/modules/exit-intent/assets/runwell-exit-intent.js +54 -0
- package/modules/exit-intent/module.json +33 -0
- package/modules/exit-intent/sections/runwell-exit-intent.liquid +48 -0
- package/modules/faq/README.md +17 -0
- package/modules/faq/module.json +35 -0
- package/modules/faq/sections/runwell-faq.liquid +66 -0
- package/modules/how-it-works/README.md +17 -0
- package/modules/how-it-works/module.json +57 -0
- package/modules/how-it-works/sections/runwell-how-it-works.liquid +99 -0
- package/modules/inventory-urgency/README.md +17 -0
- package/modules/inventory-urgency/module.json +14 -0
- package/modules/inventory-urgency/snippets/runwell-inventory-urgency.liquid +19 -0
- package/modules/pdp-ingredients/README.md +17 -0
- package/modules/pdp-ingredients/module.json +40 -0
- package/modules/pdp-ingredients/sections/runwell-ingredients.liquid +139 -0
- package/modules/pdp-journal-link/README.md +17 -0
- package/modules/pdp-journal-link/module.json +53 -0
- package/modules/pdp-journal-link/sections/runwell-pdp-journal.liquid +124 -0
- package/modules/pdp-trust-checks/README.md +17 -0
- package/modules/pdp-trust-checks/module.json +49 -0
- package/modules/pdp-trust-checks/sections/runwell-pdp-trust.liquid +141 -0
- package/modules/post-purchase-upsell/README.md +52 -0
- package/modules/post-purchase-upsell/admin/discount-setup.md +25 -0
- package/modules/post-purchase-upsell/admin/order-status-paste.html +31 -0
- package/modules/post-purchase-upsell/assets/runwell-thank-you.css +119 -0
- package/modules/post-purchase-upsell/assets/runwell-thank-you.js +162 -0
- package/modules/post-purchase-upsell/module.json +44 -0
- package/modules/press-bar/README.md +17 -0
- package/modules/press-bar/module.json +30 -0
- package/modules/press-bar/sections/runwell-press-bar.liquid +119 -0
- package/modules/recently-viewed/README.md +18 -0
- package/modules/recently-viewed/assets/runwell-recently-viewed.js +57 -0
- package/modules/recently-viewed/module.json +33 -0
- package/modules/recently-viewed/sections/runwell-recently-viewed.liquid +38 -0
- package/modules/reviews/README.md +17 -0
- package/modules/reviews/module.json +20 -0
- package/modules/reviews/sections/runwell-pdp-reviews.liquid +93 -0
- package/modules/risk-reversal/README.md +17 -0
- package/modules/risk-reversal/module.json +49 -0
- package/modules/risk-reversal/sections/runwell-risk-reversal.liquid +94 -0
- package/modules/shipping-bar/README.md +17 -0
- package/modules/shipping-bar/module.json +45 -0
- package/modules/shipping-bar/sections/runwell-shipping-bar.liquid +95 -0
- package/modules/sticky-atc/README.md +17 -0
- package/modules/sticky-atc/module.json +14 -0
- package/modules/sticky-atc/sections/runwell-pdp-sticky.liquid +78 -0
- package/modules/testimonials/README.md +17 -0
- package/modules/testimonials/module.json +35 -0
- package/modules/testimonials/sections/runwell-testimonials.liquid +87 -0
- package/modules/trust-badges/README.md +17 -0
- package/modules/trust-badges/module.json +25 -0
- package/modules/trust-badges/sections/runwell-trust-badges.liquid +93 -0
- 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
|
+
}
|
package/lib/template.js
ADDED
|
@@ -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
|
+
}
|
package/lib/validate.js
ADDED
|
@@ -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.
|