climaybe 3.0.9 → 3.1.1

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.
@@ -0,0 +1,242 @@
1
+ import { createRequire } from 'node:module';
2
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ const SCHEMA_DIR = '_schemas';
6
+ const LIQUID_DIRS = ['sections', 'blocks'];
7
+
8
+ // Matches the inline-comment marker: {% # schema 'name' %}
9
+ const MARKER_REGEX =
10
+ /\{%-?\s*#\s*schema\s+['"]([^'"]+)['"]\s*-?%\}/;
11
+
12
+ // Matches the marker, then an optional inline-override marker, then an
13
+ // optional existing generated {% schema %}...{% endschema %} block, all the
14
+ // way to end-of-file.
15
+ const MARKER_WITH_OUTPUT_REGEX =
16
+ /(\{%-?\s*#\s*schema\s+['"]([^'"]+)['"]\s*-?%\})([\s\S]*?)(\{%-?\s*schema\s*-?%\}[\s\S]*?\{%-?\s*endschema\s*-?%\})?\s*$/;
17
+
18
+ // Matches an inline-comment override: {% # { "name": "Custom" } %}
19
+ const INLINE_OVERRIDE_REGEX =
20
+ /\{%-?\s*#\s*(\{[\s\S]*?\})\s*-?%\}/;
21
+
22
+ /**
23
+ * Resolve a schema source file (.js or .json) from the schemas directory.
24
+ */
25
+ function resolveSchemaFile(schemasDir, name) {
26
+ const jsPath = join(schemasDir, `${name}.js`);
27
+ if (existsSync(jsPath)) return jsPath;
28
+ const jsonPath = join(schemasDir, `${name}.json`);
29
+ if (existsSync(jsonPath)) return jsonPath;
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Load a schema module. CommonJS `.js` files are loaded via `createRequire`;
35
+ * `.json` files are parsed directly. Cache is busted on every load so
36
+ * rebuilds pick up changes without restarting the process.
37
+ */
38
+ function loadSchemaModule(schemasDir, absolutePath) {
39
+ if (absolutePath.endsWith('.json')) {
40
+ return JSON.parse(readFileSync(absolutePath, 'utf-8'));
41
+ }
42
+
43
+ const localRequire = createRequire(join(schemasDir, '_entry.js'));
44
+ const resolved = localRequire.resolve(absolutePath);
45
+ delete localRequire.cache[resolved];
46
+ return localRequire(resolved);
47
+ }
48
+
49
+ /**
50
+ * Parse optional inline JSON.
51
+ */
52
+ function parseInlineContent(raw) {
53
+ const trimmed = (raw || '').trim();
54
+ if (!trimmed) return {};
55
+ try {
56
+ return JSON.parse(trimmed);
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Evaluate the schema export.
64
+ *
65
+ * - **Function exports** receive `(filename, inlineContent)` and must return
66
+ * the schema object.
67
+ * - **Object exports** are shallow-merged with any inline content (inline wins).
68
+ */
69
+ function evaluateSchema(schemaExport, sectionFilename, inlineContent) {
70
+ const raw = schemaExport?.default ?? schemaExport;
71
+ if (typeof raw === 'function') {
72
+ return raw(sectionFilename, inlineContent);
73
+ }
74
+ const hasInline = inlineContent && typeof inlineContent === 'object' && Object.keys(inlineContent).length > 0;
75
+ if (hasInline) {
76
+ return { ...raw, ...inlineContent };
77
+ }
78
+ return raw;
79
+ }
80
+
81
+ /**
82
+ * Process a single directory of liquid files (sections/ or blocks/).
83
+ */
84
+ function processLiquidDir({ dirPath, dirName, schemasDir, dryRun, processed, skipped, errors }) {
85
+ if (!existsSync(dirPath)) return;
86
+
87
+ const files = readdirSync(dirPath, { withFileTypes: true })
88
+ .filter((d) => d.isFile() && d.name.endsWith('.liquid'))
89
+ .map((d) => d.name)
90
+ .sort();
91
+
92
+ for (const fileName of files) {
93
+ const filePath = join(dirPath, fileName);
94
+ const displayName = `${dirName}/${fileName}`;
95
+ const content = readFileSync(filePath, 'utf-8');
96
+
97
+ if (!MARKER_REGEX.test(content)) {
98
+ skipped.push(displayName);
99
+ continue;
100
+ }
101
+
102
+ if (!existsSync(schemasDir)) {
103
+ errors.push({
104
+ section: displayName,
105
+ schema: '(unknown)',
106
+ error: '_schemas/ directory not found',
107
+ });
108
+ continue;
109
+ }
110
+
111
+ const fullMatch = content.match(MARKER_WITH_OUTPUT_REGEX);
112
+ if (!fullMatch) {
113
+ skipped.push(displayName);
114
+ continue;
115
+ }
116
+
117
+ const marker = fullMatch[1];
118
+ const schemaName = fullMatch[2];
119
+ const betweenContent = fullMatch[3] || '';
120
+
121
+ const schemaFile = resolveSchemaFile(schemasDir, schemaName);
122
+ if (!schemaFile) {
123
+ errors.push({
124
+ section: displayName,
125
+ schema: schemaName,
126
+ error: `Schema file not found: _schemas/${schemaName}.js or .json`,
127
+ });
128
+ continue;
129
+ }
130
+
131
+ try {
132
+ const schemaExport = loadSchemaModule(schemasDir, schemaFile);
133
+
134
+ let inlineContent = {};
135
+ const inlineMatch = betweenContent.match(INLINE_OVERRIDE_REGEX);
136
+ if (inlineMatch) {
137
+ inlineContent = parseInlineContent(inlineMatch[1]);
138
+ }
139
+
140
+ const schema = evaluateSchema(schemaExport, fileName, inlineContent);
141
+ const json = JSON.stringify(schema, null, 2);
142
+ const generatedBlock = `{% schema %}\n${json}\n{% endschema %}`;
143
+
144
+ const markerIndex = content.indexOf(marker);
145
+ const beforeMarker = content.substring(0, markerIndex);
146
+
147
+ let inlineOverrideBlock = '';
148
+ if (inlineMatch) {
149
+ inlineOverrideBlock = '\n' + inlineMatch[0];
150
+ }
151
+
152
+ const newContent = beforeMarker + marker + inlineOverrideBlock + '\n' + generatedBlock + '\n';
153
+
154
+ if (!dryRun) {
155
+ writeFileSync(filePath, newContent, 'utf-8');
156
+ }
157
+ processed.push({ section: displayName, schemaName });
158
+ } catch (err) {
159
+ errors.push({
160
+ section: displayName,
161
+ schema: schemaName,
162
+ error: err.message,
163
+ });
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Build section and block schemas for a Shopify theme project.
170
+ *
171
+ * Scans `sections/*.liquid` and `blocks/*.liquid` for an inline-comment marker:
172
+ *
173
+ * {% # schema 'hero-banner' %}
174
+ *
175
+ * When found, resolves `_schemas/hero-banner.js` (or `.json`), evaluates it,
176
+ * and writes (or replaces) the generated `{% schema %}...{% endschema %}`
177
+ * block directly below the marker. The marker is never removed, so rebuilds
178
+ * always work — even after Shopify theme editor edits.
179
+ *
180
+ * @param {object} options
181
+ * @param {string} [options.cwd] Theme project root (default `process.cwd()`).
182
+ * @param {boolean} [options.dryRun] When true, compute schemas without writing files.
183
+ * @returns {{ processed: Array<{section: string, schemaName: string}>, skipped: string[], errors: Array<{section: string, schema: string, error: string}> }}
184
+ */
185
+ export function buildSchemas({ cwd = process.cwd(), dryRun = false } = {}) {
186
+ const schemasDir = join(cwd, SCHEMA_DIR);
187
+
188
+ const processed = [];
189
+ const skipped = [];
190
+ const errors = [];
191
+
192
+ for (const dirName of LIQUID_DIRS) {
193
+ processLiquidDir({
194
+ dirPath: join(cwd, dirName),
195
+ dirName,
196
+ schemasDir,
197
+ dryRun,
198
+ processed,
199
+ skipped,
200
+ errors,
201
+ });
202
+ }
203
+
204
+ return { processed, skipped, errors };
205
+ }
206
+
207
+ /**
208
+ * List available schema source files in `_schemas/`.
209
+ */
210
+ export function listSchemaFiles(cwd = process.cwd()) {
211
+ const schemasDir = join(cwd, SCHEMA_DIR);
212
+ if (!existsSync(schemasDir)) return [];
213
+ return readdirSync(schemasDir, { withFileTypes: true })
214
+ .filter((d) => d.isFile() && (d.name.endsWith('.js') || d.name.endsWith('.json')))
215
+ .map((d) => d.name)
216
+ .sort();
217
+ }
218
+
219
+ /**
220
+ * List liquid files in sections/ and blocks/ that contain the inline-comment schema marker.
221
+ */
222
+ export function listSectionsWithSchemaRefs(cwd = process.cwd()) {
223
+ const results = [];
224
+
225
+ for (const dirName of LIQUID_DIRS) {
226
+ const dirPath = join(cwd, dirName);
227
+ if (!existsSync(dirPath)) continue;
228
+
229
+ const files = readdirSync(dirPath, { withFileTypes: true })
230
+ .filter((d) => d.isFile() && d.name.endsWith('.liquid'));
231
+
232
+ for (const file of files) {
233
+ const content = readFileSync(join(dirPath, file.name), 'utf-8');
234
+ const match = content.match(MARKER_REGEX);
235
+ if (match) {
236
+ results.push({ section: `${dirName}/${file.name}`, schemas: [match[1]] });
237
+ }
238
+ }
239
+ }
240
+
241
+ return results;
242
+ }
@@ -14,11 +14,14 @@ ignore:
14
14
  `,
15
15
  '.shopifyignore': `_styles
16
16
  _scripts
17
+ .cursor
17
18
  .cursorrules
18
19
  .config
19
20
  .backups
20
21
  .github
21
22
  .vscode
23
+ tools
24
+ stores
22
25
  node_modules
23
26
  .gitignore
24
27
  LICENSE
@@ -45,7 +45,19 @@ function canPromptForUpdate() {
45
45
  return Boolean(input.isTTY && output.isTTY && process.env.CI !== 'true');
46
46
  }
47
47
 
48
- export function resolveInstallScope({ packageDir, cwd = process.cwd() } = {}) {
48
+ function isNodeModulesInstall(packageDir, packageName) {
49
+ if (!packageDir || !packageName) return false;
50
+ const normalized = resolve(packageDir);
51
+ const nm = `${join('node_modules', packageName)}`;
52
+ return normalized.includes(`${join('node_modules', '')}`) && normalized.endsWith(nm);
53
+ }
54
+
55
+ export function resolveInstallScope({ packageName, packageDir, cwd = process.cwd() } = {}) {
56
+ // Prefer using the *running* CLI install path to decide how to update.
57
+ // This prevents "update loops" where we update a project dependency while the
58
+ // user is actually running a global install (or vice-versa).
59
+ if (isNodeModulesInstall(packageDir, packageName)) return 'local';
60
+
49
61
  try {
50
62
  const globalRoot = execSync('npm root -g', { encoding: 'utf-8', stdio: 'pipe' }).trim();
51
63
  if (packageDir && resolve(packageDir).startsWith(resolve(globalRoot))) return 'global';
@@ -53,7 +65,8 @@ export function resolveInstallScope({ packageDir, cwd = process.cwd() } = {}) {
53
65
  // ignore and fallback to local checks
54
66
  }
55
67
 
56
- if (existsSync(join(cwd, 'package.json'))) return 'local';
68
+ // If we don't have an install path, fall back to "am I in a project?"
69
+ if (!packageDir && existsSync(join(cwd, 'package.json'))) return 'local';
57
70
  return 'global';
58
71
  }
59
72
 
@@ -73,13 +86,14 @@ export function getLocalInstallFlag({ packageName, cwd = process.cwd() } = {}) {
73
86
  }
74
87
 
75
88
  function runUpdate(packageName, { packageDir, cwd = process.cwd() } = {}) {
76
- const scope = resolveInstallScope({ packageDir, cwd });
89
+ const scope = resolveInstallScope({ packageName, packageDir, cwd });
77
90
  if (scope === 'global') {
78
91
  execSync(`npm install -g ${packageName}@latest`, { stdio: 'inherit' });
79
92
  return 'global';
80
93
  }
81
- const flag = getLocalInstallFlag({ packageName, cwd });
82
- execSync(`npm install ${packageName}@latest ${flag}`, { cwd, stdio: 'inherit' });
94
+ const installCwd = cwd || process.cwd();
95
+ const flag = getLocalInstallFlag({ packageName, cwd: installCwd });
96
+ execSync(`npm install ${packageName}@latest ${flag}`, { cwd: installCwd, stdio: 'inherit' });
83
97
  return 'local';
84
98
  }
85
99
 
@@ -5,6 +5,17 @@ name: Build Pipeline
5
5
  on:
6
6
  push:
7
7
  branches: ['**']
8
+ paths-ignore:
9
+ - '**/*.md'
10
+ - 'docs/**'
11
+ - '.github/**'
12
+ - '.cursor/**'
13
+ - '.claude/**'
14
+ - '.eser/**'
15
+ - 'README*'
16
+ - 'LICENSE*'
17
+ - 'CHANGELOG*'
18
+ - 'CONTRIBUTING*'
8
19
 
9
20
  jobs:
10
21
  build:
@@ -15,11 +26,13 @@ jobs:
15
26
  !contains(github.event.head_commit.message, '[hotfix-backport]')
16
27
  && !contains(github.event.head_commit.message, '[stores-to-root]')
17
28
  && !contains(github.event.head_commit.message, '[root-to-stores]')
29
+ && !(github.actor == 'github-actions[bot]' && contains(github.event.head_commit.message, 'chore(assets): update compiled javascript and css'))
18
30
  uses: ./.github/workflows/reusable-build.yml
19
31
 
20
32
  lighthouse-gate:
21
33
  runs-on: ubuntu-latest
22
34
  needs: [build]
35
+ if: needs.build.outputs.build_ran == 'true'
23
36
  outputs:
24
37
  run_lighthouse: ${{ steps.check.outputs.run_lighthouse }}
25
38
  env:
@@ -31,8 +44,11 @@ jobs:
31
44
  id: check
32
45
  run: |
33
46
  SKIP_REASONS=()
34
- if [ "${{ github.ref }}" != "refs/heads/main" ] && [ "${{ github.ref }}" != "refs/heads/staging" ]; then
35
- SKIP_REASONS+=("Not on main or staging branch (current: ${{ github.ref }})")
47
+ BR="${{ github.ref_name }}"
48
+ if [[ "$BR" == "staging" ]]; then
49
+ :
50
+ else
51
+ SKIP_REASONS+=("Lighthouse runs only on branch staging (current branch: $BR)")
36
52
  fi
37
53
  if [ -z "$SHOPIFY_STORE_URL" ] || [ -z "$SHOP_ACCESS_TOKEN" ] || [ -z "$LHCI_GITHUB_APP_TOKEN" ]; then
38
54
  SKIP_REASONS+=("Missing required secrets: SHOPIFY_STORE_URL, SHOP_ACCESS_TOKEN, or LHCI_GITHUB_APP_TOKEN")
@@ -6,6 +6,9 @@ on:
6
6
  build-success:
7
7
  description: "Whether build was successful"
8
8
  value: ${{ jobs.build.outputs.success }}
9
+ build_ran:
10
+ description: "True when npm ci and at least one build step ran (path filters matched)"
11
+ value: ${{ jobs.build.outputs.build_ran }}
9
12
 
10
13
  jobs:
11
14
  build:
@@ -14,22 +17,25 @@ jobs:
14
17
  contents: write
15
18
  outputs:
16
19
  success: ${{ steps.build.outputs.success }}
20
+ build_ran: ${{ steps.run.outputs.build_ran }}
17
21
  steps:
18
22
  - name: Checkout code
19
23
  uses: actions/checkout@v4
20
24
 
21
- - name: Set up Node.js
22
- uses: actions/setup-node@v4
25
+ - name: Detect changed paths
26
+ uses: dorny/paths-filter@v3
27
+ id: changes
23
28
  with:
24
- node-version: "24"
25
-
26
- - name: Install dependencies from lockfile
27
- run: |
28
- if [ ! -f "package-lock.json" ]; then
29
- echo "package-lock.json is required for deterministic build workflow installs."
30
- exit 1
31
- fi
32
- npm ci
29
+ filters: |
30
+ scripts:
31
+ - '_scripts/**/*.js'
32
+ - 'assets/**/*.js'
33
+ tailwind:
34
+ - '**/*.liquid'
35
+ - '**/*.css'
36
+ - '_scripts/**/*.js'
37
+ - 'tailwind.config.*'
38
+ - 'postcss.config.*'
33
39
 
34
40
  - name: Detect build entrypoints
35
41
  id: detect
@@ -51,8 +57,42 @@ jobs:
51
57
  echo "scripts_minify=false" >> $GITHUB_OUTPUT
52
58
  fi
53
59
 
60
+ - name: Decide which build steps to run
61
+ id: run
62
+ run: |
63
+ SCRIPTS="${{ steps.changes.outputs.scripts }}"
64
+ TW="${{ steps.changes.outputs.tailwind }}"
65
+ HAS_SCR="${{ steps.detect.outputs.has_scripts }}"
66
+ HAS_STY="${{ steps.detect.outputs.has_styles }}"
67
+ run_scripts=false
68
+ run_tailwind=false
69
+ if [ "$SCRIPTS" = "true" ] && [ "$HAS_SCR" = "true" ]; then run_scripts=true; fi
70
+ if [ "$TW" = "true" ] && [ "$HAS_STY" = "true" ]; then run_tailwind=true; fi
71
+ if [ "$run_scripts" = "true" ] || [ "$run_tailwind" = "true" ]; then
72
+ echo "build_ran=true" >> $GITHUB_OUTPUT
73
+ else
74
+ echo "build_ran=false" >> $GITHUB_OUTPUT
75
+ fi
76
+ echo "run_scripts=$run_scripts" >> $GITHUB_OUTPUT
77
+ echo "run_tailwind=$run_tailwind" >> $GITHUB_OUTPUT
78
+
79
+ - name: Set up Node.js
80
+ if: steps.run.outputs.build_ran == 'true'
81
+ uses: actions/setup-node@v4
82
+ with:
83
+ node-version: "24"
84
+
85
+ - name: Install dependencies from lockfile
86
+ if: steps.run.outputs.build_ran == 'true'
87
+ run: |
88
+ if [ ! -f "package-lock.json" ]; then
89
+ echo "package-lock.json is required for deterministic build workflow installs."
90
+ exit 1
91
+ fi
92
+ npm ci
93
+
54
94
  - name: Build scripts
55
- if: steps.detect.outputs.has_scripts == 'true'
95
+ if: steps.run.outputs.build_ran == 'true' && steps.run.outputs.run_scripts == 'true'
56
96
  run: |
57
97
  if [ "${{ steps.detect.outputs.scripts_minify }}" = "true" ]; then
58
98
  npx --no-install climaybe build-scripts --minify
@@ -61,16 +101,12 @@ jobs:
61
101
  fi
62
102
 
63
103
  - name: Run Tailwind build
64
- id: build
104
+ if: steps.run.outputs.build_ran == 'true' && steps.run.outputs.run_tailwind == 'true'
65
105
  run: |
66
- if [ "${{ steps.detect.outputs.has_styles }}" = "true" ]; then
67
- NODE_ENV=production npx --no-install climaybe build
68
- else
69
- echo "No _styles/main.css found; skipping Tailwind build."
70
- fi
71
- echo "success=true" >> $GITHUB_OUTPUT
106
+ NODE_ENV=production npx --no-install climaybe build
72
107
 
73
108
  - name: Commit and push changes
109
+ if: steps.run.outputs.build_ran == 'true'
74
110
  run: |
75
111
  git config --local user.email "action@github.com"
76
112
  git config --local user.name "GitHub Action"
@@ -89,3 +125,8 @@ jobs:
89
125
  git commit -m "chore(assets): update compiled javascript and css"
90
126
  git push origin "$BRANCH_NAME"
91
127
  fi
128
+
129
+ - name: Finalize success
130
+ id: build
131
+ if: success()
132
+ run: echo "success=true" >> $GITHUB_OUTPUT
@@ -141,7 +141,10 @@ jobs:
141
141
  exit 0
142
142
  fi
143
143
 
144
- if ! git merge origin/main --no-ff -m "Sync main $BRANCH"; then
144
+ # Keep generated assets branch-local to avoid chronic merge conflicts on staging branches.
145
+ # This does NOT prevent main changes from landing; it only resolves conflicts in favor
146
+ # of the current staging-<alias> branch when the same file was modified on both sides.
147
+ if ! git merge -X ours origin/main --no-ff -m "Sync main → $BRANCH"; then
145
148
  echo "Merge had conflicts; aborting. Manual resolution may be needed."
146
149
  git merge --abort 2>/dev/null || true
147
150
  exit 1
@@ -10,10 +10,38 @@ on:
10
10
  branches: [main, staging, develop, 'staging-*', 'live-*']
11
11
 
12
12
  jobs:
13
+ filter:
14
+ runs-on: ubuntu-latest
15
+ outputs:
16
+ theme: ${{ steps.paths.outputs.theme }}
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: dorny/paths-filter@v3
20
+ id: paths
21
+ with:
22
+ filters: |
23
+ theme:
24
+ - 'assets/**'
25
+ - 'blocks/**'
26
+ - 'config/**'
27
+ - 'layout/**'
28
+ - 'locales/**'
29
+ - 'sections/**'
30
+ - 'snippets/**'
31
+ - 'templates/**'
32
+ - '_scripts/**'
33
+ - '_styles/**'
34
+ - 'shopify.theme.toml'
35
+ - 'stores/**'
36
+
13
37
  extract-pr-number:
38
+ needs: [filter]
39
+ if: needs.filter.outputs.theme == 'true'
14
40
  uses: ./.github/workflows/reusable-extract-pr-number.yml
15
41
 
16
42
  validate-environment:
43
+ needs: [filter]
44
+ if: needs.filter.outputs.theme == 'true'
17
45
  runs-on: ubuntu-latest
18
46
  outputs:
19
47
  store_alias: ${{ steps.resolve.outputs.alias }}