tiendu 0.6.0 → 0.7.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/lib/build.mjs CHANGED
@@ -10,8 +10,7 @@ import {
10
10
  } from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import * as esbuild from "esbuild";
13
- import * as p from "@clack/prompts";
14
- import { readThemeConfig } from "./config.mjs";
13
+ import { getThemePipelineConfig, readThemeConfig } from "./config.mjs";
15
14
  import {
16
15
  flattenAssetLogicalPath,
17
16
  getAssetImportFilter,
@@ -22,17 +21,26 @@ import {
22
21
  } from "./assets.mjs";
23
22
  import { listFilesRecursive } from "./fs-utils.mjs";
24
23
  import { createCssPostCssPlugin } from "./postcss.mjs";
25
-
26
- const THEME_SOURCE_OUTPUT_DIRS = ["layout", "templates", "sections", "snippets", "config"];
24
+ import * as ui from "./ui.mjs";
25
+
26
+ const THEME_SOURCE_OUTPUT_DIRS = [
27
+ "layout",
28
+ "templates",
29
+ "sections",
30
+ "blocks",
31
+ "snippets",
32
+ "config",
33
+ ];
27
34
  const LIQUID_LIKE_EXTENSIONS = new Set([".liquid", ".html", ".htm"]);
28
35
  const ENTRY_SOURCE_EXTENSIONS = new Set([".js", ".ts", ".css"]);
29
- const NESTED_ASSET_PATH_PATTERN = /\/assets\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._/-]+)+)([?#][A-Za-z0-9=&._-]+)?/g;
36
+ const NESTED_ASSET_PATH_PATTERN =
37
+ /\/assets\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._/-]+)+)([?#][A-Za-z0-9=&._-]+)?/g;
30
38
 
31
39
  /**
32
- * Discover JS/TS and CSS entry points from src/layout and src/templates.
40
+ * Discover optional JS/TS and CSS entry points from src/layout/templates or layout/templates.
33
41
  * Returns separate maps for JS and CSS to avoid key collisions.
34
42
  */
35
- const discoverEntryPoints = async (srcDir) => {
43
+ const discoverEntryPoints = async (rootDir) => {
36
44
  const jsEntries = {};
37
45
  const cssEntries = {};
38
46
 
@@ -40,24 +48,29 @@ const discoverEntryPoints = async (srcDir) => {
40
48
  ["layout", "layout"],
41
49
  ["templates", "template"],
42
50
  ]) {
43
- const dirPath = path.join(srcDir, dir);
44
- let files;
45
- try {
46
- files = await readdir(dirPath);
47
- } catch {
48
- continue;
49
- }
50
- for (const file of files) {
51
- const ext = path.extname(file);
52
- if (![".js", ".ts", ".css"].includes(ext)) continue;
53
- const name = path.basename(file, ext);
54
- const key = `${prefix}-${name}.bundle`;
55
- const fullPath = path.join(dirPath, file);
56
- if (ext === ".css") {
57
- cssEntries[key] = fullPath;
58
- } else {
59
- jsEntries[key] = fullPath;
51
+ const sourceCandidates = [path.join(rootDir, "src", dir), path.join(rootDir, dir)];
52
+
53
+ for (const dirPath of sourceCandidates) {
54
+ let files;
55
+ try {
56
+ files = await readdir(dirPath);
57
+ } catch {
58
+ continue;
60
59
  }
60
+ for (const file of files) {
61
+ const ext = path.extname(file);
62
+ if (![".js", ".ts", ".css"].includes(ext)) continue;
63
+ const name = path.basename(file, ext);
64
+ const key = `${prefix}-${name}.bundle`;
65
+ const fullPath = path.join(dirPath, file);
66
+ if (ext === ".css") {
67
+ cssEntries[key] = fullPath;
68
+ } else {
69
+ jsEntries[key] = fullPath;
70
+ }
71
+ }
72
+
73
+ break;
61
74
  }
62
75
  }
63
76
 
@@ -123,22 +136,37 @@ const copyThemeSourceFile = async (
123
136
 
124
137
  if (LIQUID_LIKE_EXTENSIONS.has(path.extname(src).toLowerCase())) {
125
138
  const source = await readFile(src, "utf-8");
126
- await writeFile(dest, rewriteDirectAssetPaths(source, knownAssetLogicalPaths), "utf-8");
139
+ await writeFile(
140
+ dest,
141
+ rewriteDirectAssetPaths(source, knownAssetLogicalPaths),
142
+ "utf-8",
143
+ );
127
144
  return;
128
145
  }
129
146
 
130
147
  await copyFile(src, dest);
131
148
  };
132
149
 
133
- const copyThemeFiles = async (rootDir, distDir, themeSourceDirs, knownAssetLogicalPaths) => {
150
+ const copyThemeFiles = async (
151
+ rootDir,
152
+ distDir,
153
+ themeSourceDirs,
154
+ knownAssetLogicalPaths,
155
+ ) => {
134
156
  for (const sourceDir of themeSourceDirs) {
135
157
  const absoluteSourceDir = path.join(rootDir, sourceDir.sourceRelativeDir);
136
158
  const absoluteFiles = await listFilesRecursive(absoluteSourceDir);
137
159
 
138
160
  for (const absolutePath of absoluteFiles) {
139
161
  const nestedRelativePath = path.relative(absoluteSourceDir, absolutePath);
140
- const sourceRelativePath = path.join(sourceDir.sourceRelativeDir, nestedRelativePath);
141
- const outputRelativePath = path.join(sourceDir.outputRelativeDir, nestedRelativePath);
162
+ const sourceRelativePath = path.join(
163
+ sourceDir.sourceRelativeDir,
164
+ nestedRelativePath,
165
+ );
166
+ const outputRelativePath = path.join(
167
+ sourceDir.outputRelativeDir,
168
+ nestedRelativePath,
169
+ );
142
170
  if (!shouldCopyThemeSourceFile(sourceRelativePath)) continue;
143
171
  await copyThemeSourceFile(
144
172
  rootDir,
@@ -155,11 +183,17 @@ const shouldTriggerTailwindCssRebuild = (relativePath) => {
155
183
  const normalizedPath = relativePath.split(path.sep).join("/");
156
184
  const extension = path.extname(normalizedPath).toLowerCase();
157
185
 
158
- if (!["layout/", "templates/", "sections/", "snippets/"].some((prefix) => normalizedPath.startsWith(prefix))) {
186
+ if (
187
+ !["layout/", "templates/", "sections/", "blocks/", "snippets/"].some(
188
+ (prefix) => normalizedPath.startsWith(prefix),
189
+ )
190
+ ) {
159
191
  return false;
160
192
  }
161
193
 
162
- return [".liquid", ".html", ".htm", ".js", ".ts", ".json", ".md"].includes(extension);
194
+ return [".liquid", ".html", ".htm", ".js", ".ts", ".json", ".md"].includes(
195
+ extension,
196
+ );
163
197
  };
164
198
 
165
199
  const createSourceAssetImportPlugin = (rootDir, sourceDirs) => ({
@@ -173,7 +207,11 @@ const createSourceAssetImportPlugin = (rootDir, sourceDirs) => ({
173
207
  const candidatePath = path.isAbsolute(args.path)
174
208
  ? args.path
175
209
  : path.resolve(args.resolveDir, args.path);
176
- const assetInfo = await getAssetSourceInfo(rootDir, candidatePath, sourceDirs);
210
+ const assetInfo = await getAssetSourceInfo(
211
+ rootDir,
212
+ candidatePath,
213
+ sourceDirs,
214
+ );
177
215
  if (!assetInfo) return null;
178
216
 
179
217
  return {
@@ -183,14 +221,17 @@ const createSourceAssetImportPlugin = (rootDir, sourceDirs) => ({
183
221
  };
184
222
  });
185
223
 
186
- build.onLoad({ filter: /.*/, namespace: "tiendu-source-asset" }, async (args) => {
187
- const assetInfo = args.pluginData;
188
- return {
189
- contents: `export default ${JSON.stringify(`/${assetInfo.outputRelativePath}`)};`,
190
- loader: "js",
191
- watchFiles: [assetInfo.absolutePath],
192
- };
193
- });
224
+ build.onLoad(
225
+ { filter: /.*/, namespace: "tiendu-source-asset" },
226
+ async (args) => {
227
+ const assetInfo = args.pluginData;
228
+ return {
229
+ contents: `export default ${JSON.stringify(`/${assetInfo.outputRelativePath}`)};`,
230
+ loader: "js",
231
+ watchFiles: [assetInfo.absolutePath],
232
+ };
233
+ },
234
+ );
194
235
  },
195
236
  });
196
237
 
@@ -208,12 +249,15 @@ const runEntryBuilds = async (jsBuildOptions, cssBuildOptions) => {
208
249
  */
209
250
  export const build = async ({ watch: watchMode = false } = {}) => {
210
251
  const rootDir = process.cwd();
211
- const srcDir = path.join(rootDir, "src");
212
252
  const distDir = path.join(rootDir, "dist");
213
253
 
214
254
  const themeConfig = await readThemeConfig();
215
- if (!themeConfig) {
216
- p.log.error("No tiendu.config.json found. This is not a built theme.");
255
+ const pipeline = getThemePipelineConfig(themeConfig);
256
+
257
+ if (pipeline.postcss && !pipeline.compileStyles) {
258
+ ui.log.error(
259
+ "Invalid tiendu.config.json: pipeline.postcss requires pipeline.compileStyles to be enabled.",
260
+ );
217
261
  return { ok: false };
218
262
  }
219
263
 
@@ -222,10 +266,14 @@ export const build = async ({ watch: watchMode = false } = {}) => {
222
266
  await mkdir(distDir, { recursive: true });
223
267
 
224
268
  const themeSourceDirs = await getThemeSourceDirs(rootDir);
225
- const staticAssetSourceDirs = await getStaticAssetSourceDirs(rootDir, { refresh: true });
269
+ const staticAssetSourceDirs = await getStaticAssetSourceDirs(rootDir, {
270
+ refresh: true,
271
+ });
226
272
 
227
273
  // Discover entry points (JS and CSS separately to avoid key collisions)
228
- const { jsEntries, cssEntries } = await discoverEntryPoints(srcDir);
274
+ const discoveredEntries = await discoverEntryPoints(rootDir);
275
+ const jsEntries = pipeline.compileScripts ? discoveredEntries.jsEntries : {};
276
+ const cssEntries = pipeline.compileStyles ? discoveredEntries.cssEntries : {};
229
277
  const jsCount = Object.keys(jsEntries).length;
230
278
  const cssCount = Object.keys(cssEntries).length;
231
279
  const entryCount = jsCount + cssCount;
@@ -236,7 +284,9 @@ export const build = async ({ watch: watchMode = false } = {}) => {
236
284
  let staticAssetOwners = new Map();
237
285
  const knownAssetLogicalPaths = new Set();
238
286
  const cssPlugins = [];
239
- const jsPlugins = [createSourceAssetImportPlugin(rootDir, staticAssetSourceDirs)];
287
+ const jsPlugins = pipeline.compileScripts
288
+ ? [createSourceAssetImportPlugin(rootDir, staticAssetSourceDirs)]
289
+ : [];
240
290
 
241
291
  try {
242
292
  staticAssetOwners = await syncStaticAssets(
@@ -246,7 +296,7 @@ export const build = async ({ watch: watchMode = false } = {}) => {
246
296
  staticAssetSourceDirs,
247
297
  );
248
298
  } catch (error) {
249
- p.log.error(`Static asset build failed: ${error.message}`);
299
+ ui.log.error(`Static asset build failed: ${error.message}`);
250
300
  return { ok: false };
251
301
  }
252
302
 
@@ -255,42 +305,82 @@ export const build = async ({ watch: watchMode = false } = {}) => {
255
305
  }
256
306
 
257
307
  // Copy theme files after asset paths are known
258
- await copyThemeFiles(rootDir, distDir, themeSourceDirs, knownAssetLogicalPaths);
308
+ await copyThemeFiles(
309
+ rootDir,
310
+ distDir,
311
+ themeSourceDirs,
312
+ knownAssetLogicalPaths,
313
+ );
259
314
 
260
- if (cssCount > 0) {
315
+ if (cssCount > 0 && pipeline.postcss) {
261
316
  try {
262
317
  cssPlugins.push(
263
- await createCssPostCssPlugin(rootDir, { sourceDirs: staticAssetSourceDirs }),
318
+ await createCssPostCssPlugin(rootDir, {
319
+ sourceDirs: staticAssetSourceDirs,
320
+ }),
264
321
  );
265
322
  } catch (error) {
266
- p.log.error(`CSS pipeline failed to initialize: ${error.message}`);
323
+ ui.log.error(`CSS pipeline failed to initialize: ${error.message}`);
267
324
  return { ok: false };
268
325
  }
269
326
  }
270
327
 
271
328
  if (entryCount === 0) {
272
- p.log.warn("No entry points found in src/layout or src/templates.");
273
- return { ok: true };
329
+ const hasThemeFiles =
330
+ themeSourceDirs.length > 0 || staticAssetSourceDirs.length > 0;
331
+
332
+ if (!hasThemeFiles) {
333
+ ui.log.error("No theme source files or entry points found.");
334
+ return { ok: false };
335
+ }
336
+
337
+ if (!watchMode) {
338
+ ui.log.success("Prepared theme files to dist/.");
339
+ return { ok: true };
340
+ }
274
341
  }
275
342
 
276
343
  const outdir = path.join(distDir, "assets");
277
- const jsBuildOptions = jsCount > 0
278
- ? { entryPoints: jsEntries, bundle: true, format: "esm", target: "es2020", outdir, logLevel: "warning", write: true, resolveExtensions: [".ts", ".tsx", ".js", ".jsx", ".json"], plugins: jsPlugins }
279
- : null;
280
- const cssBuildOptions = cssCount > 0
281
- ? { entryPoints: cssEntries, bundle: true, outdir, logLevel: "warning", write: true, plugins: cssPlugins }
282
- : null;
344
+ const jsBuildOptions =
345
+ jsCount > 0
346
+ ? {
347
+ entryPoints: jsEntries,
348
+ bundle: true,
349
+ format: "esm",
350
+ target: "es2020",
351
+ outdir,
352
+ logLevel: "warning",
353
+ write: true,
354
+ resolveExtensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
355
+ plugins: jsPlugins,
356
+ }
357
+ : null;
358
+ const cssBuildOptions =
359
+ cssCount > 0
360
+ ? {
361
+ entryPoints: cssEntries,
362
+ bundle: true,
363
+ outdir,
364
+ logLevel: "warning",
365
+ write: true,
366
+ plugins: cssPlugins,
367
+ }
368
+ : null;
283
369
 
284
370
  if (!watchMode) {
285
371
  // One-shot build
286
372
  try {
287
373
  await runEntryBuilds(jsBuildOptions, cssBuildOptions);
288
- p.log.success(
289
- `Built ${entryCount} entry point${entryCount === 1 ? "" : "s"} to dist/`,
290
- );
374
+ if (entryCount === 0) {
375
+ ui.log.success("Prepared theme files to dist/.");
376
+ } else {
377
+ ui.log.success(
378
+ `Built ${entryCount} entry point${entryCount === 1 ? "" : "s"} to dist/`,
379
+ );
380
+ }
291
381
  return { ok: true };
292
382
  } catch (error) {
293
- p.log.error(`Build failed: ${error.message}`);
383
+ ui.log.error(`Build failed: ${error.message}`);
294
384
  return { ok: false };
295
385
  }
296
386
  }
@@ -312,14 +402,18 @@ export const build = async ({ watch: watchMode = false } = {}) => {
312
402
  contexts.push(cssCtx);
313
403
  }
314
404
  } catch (error) {
315
- p.log.error(`Build failed: ${error.message}`);
405
+ ui.log.error(`Build failed: ${error.message}`);
316
406
  for (const ctx of contexts) await ctx.dispose();
317
407
  return { ok: false };
318
408
  }
319
409
 
320
- p.log.success(
321
- `Built ${entryCount} entry point${entryCount === 1 ? "" : "s"}. Watching for changes...`,
322
- );
410
+ if (entryCount === 0) {
411
+ ui.log.success("Prepared theme files. Watching for changes...");
412
+ } else {
413
+ ui.log.success(
414
+ `Built ${entryCount} entry point${entryCount === 1 ? "" : "s"}. Watching for changes...`,
415
+ );
416
+ }
323
417
 
324
418
  // Watch theme directories for Liquid/static asset changes and copy to dist
325
419
  const themeWatchers = [];
@@ -340,9 +434,9 @@ export const build = async ({ watch: watchMode = false } = {}) => {
340
434
 
341
435
  try {
342
436
  await cssCtx.rebuild();
343
- console.log("CSS bundles");
437
+ console.log("CSS bundles updated");
344
438
  } catch (error) {
345
- p.log.warn(`Error rebuilding CSS: ${error.message}`);
439
+ ui.log.warn(`Error rebuilding CSS: ${error.message}`);
346
440
  } finally {
347
441
  cssRebuildInFlight = false;
348
442
  if (cssRebuildQueued) {
@@ -380,83 +474,98 @@ export const build = async ({ watch: watchMode = false } = {}) => {
380
474
  knownAssetLogicalPaths.delete(result.logicalPath);
381
475
  }
382
476
 
383
- console.log(`${result.type === "delete" ? "✕" : "⟳"} ${result.outputRelativePath}`);
477
+ console.log(
478
+ `${result.type === "delete" ? "DELETE" : "UPDATE"} ${result.outputRelativePath}`,
479
+ );
384
480
  };
385
481
 
386
482
  for (const sourceDir of themeSourceDirs) {
387
483
  const dirPath = path.join(rootDir, sourceDir.sourceRelativeDir);
388
484
 
389
- const watcher = watch(dirPath, { recursive: true }, (eventType, filename) => {
390
- if (!filename) return;
391
- const normalizedFilename = filename.split(path.sep).join("/");
392
- const sourceRelativePath = `${sourceDir.sourceRelativeDir}/${normalizedFilename}`;
393
- const outputRelativePath = `${sourceDir.outputRelativeDir}/${normalizedFilename}`;
394
-
395
- const existing = debounceMap.get(sourceRelativePath);
396
- if (existing) clearTimeout(existing);
397
-
398
- const timer = setTimeout(async () => {
399
- debounceMap.delete(sourceRelativePath);
400
- try {
401
- if (!shouldCopyThemeSourceFile(sourceRelativePath)) {
402
- return;
403
- }
404
-
405
- const fileStat = await stat(path.join(rootDir, sourceRelativePath)).catch(
406
- () => null,
407
- );
408
- if (fileStat && fileStat.isFile()) {
409
- await copyThemeSourceFile(
410
- rootDir,
411
- distDir,
412
- sourceRelativePath,
413
- outputRelativePath,
414
- knownAssetLogicalPaths,
485
+ const watcher = watch(
486
+ dirPath,
487
+ { recursive: true },
488
+ (eventType, filename) => {
489
+ if (!filename) return;
490
+ const normalizedFilename = filename.split(path.sep).join("/");
491
+ const sourceRelativePath = `${sourceDir.sourceRelativeDir}/${normalizedFilename}`;
492
+ const outputRelativePath = `${sourceDir.outputRelativeDir}/${normalizedFilename}`;
493
+
494
+ const existing = debounceMap.get(sourceRelativePath);
495
+ if (existing) clearTimeout(existing);
496
+
497
+ const timer = setTimeout(async () => {
498
+ debounceMap.delete(sourceRelativePath);
499
+ try {
500
+ if (!shouldCopyThemeSourceFile(sourceRelativePath)) {
501
+ return;
502
+ }
503
+
504
+ const fileStat = await stat(
505
+ path.join(rootDir, sourceRelativePath),
506
+ ).catch(() => null);
507
+ if (fileStat && fileStat.isFile()) {
508
+ await copyThemeSourceFile(
509
+ rootDir,
510
+ distDir,
511
+ sourceRelativePath,
512
+ outputRelativePath,
513
+ knownAssetLogicalPaths,
514
+ );
515
+ console.log(`UPDATE ${outputRelativePath}`);
516
+ } else if (!fileStat) {
517
+ // File deleted — remove from dist
518
+ const dest = path.join(distDir, outputRelativePath);
519
+ await rm(dest, { force: true });
520
+ console.log(`DELETE ${outputRelativePath}`);
521
+ }
522
+
523
+ if (
524
+ pipeline.compileStyles &&
525
+ shouldTriggerTailwindCssRebuild(outputRelativePath)
526
+ ) {
527
+ queueCssRebuild();
528
+ }
529
+ } catch (error) {
530
+ ui.log.warn(
531
+ `Error copying ${sourceRelativePath}: ${error.message}`,
415
532
  );
416
- console.log(`⟳ ${outputRelativePath}`);
417
- } else if (!fileStat) {
418
- // File deleted — remove from dist
419
- const dest = path.join(distDir, outputRelativePath);
420
- await rm(dest, { force: true });
421
- console.log(`✕ ${outputRelativePath}`);
422
533
  }
534
+ }, DEBOUNCE_MS);
423
535
 
424
- if (shouldTriggerTailwindCssRebuild(outputRelativePath)) {
425
- queueCssRebuild();
426
- }
427
- } catch (error) {
428
- p.log.warn(`Error copying ${sourceRelativePath}: ${error.message}`);
429
- }
430
- }, DEBOUNCE_MS);
431
-
432
- debounceMap.set(sourceRelativePath, timer);
433
- });
536
+ debounceMap.set(sourceRelativePath, timer);
537
+ },
538
+ );
434
539
 
435
540
  themeWatchers.push(watcher);
436
541
  }
437
542
 
438
543
  for (const assetDir of await getStaticAssetSourceDirs(rootDir)) {
439
- const watcher = watch(assetDir.absoluteDir, { recursive: true }, (eventType, filename) => {
440
- if (!filename) return;
441
- const relativePath = `${assetDir.relativeDir}/${filename.split(path.sep).join("/")}`;
442
-
443
- const existing = debounceMap.get(relativePath);
444
- if (existing) clearTimeout(existing);
445
-
446
- const timer = setTimeout(async () => {
447
- debounceMap.delete(relativePath);
448
- try {
449
- await handleStaticAssetChange(relativePath);
450
- } catch (error) {
451
- const errorLabel = error.message.includes("Asset collision")
452
- ? "Asset collision"
453
- : "Error compiling";
454
- p.log.warn(`${errorLabel} ${relativePath}: ${error.message}`);
455
- }
456
- }, DEBOUNCE_MS);
544
+ const watcher = watch(
545
+ assetDir.absoluteDir,
546
+ { recursive: true },
547
+ (eventType, filename) => {
548
+ if (!filename) return;
549
+ const relativePath = `${assetDir.relativeDir}/${filename.split(path.sep).join("/")}`;
550
+
551
+ const existing = debounceMap.get(relativePath);
552
+ if (existing) clearTimeout(existing);
553
+
554
+ const timer = setTimeout(async () => {
555
+ debounceMap.delete(relativePath);
556
+ try {
557
+ await handleStaticAssetChange(relativePath);
558
+ } catch (error) {
559
+ const errorLabel = error.message.includes("Asset collision")
560
+ ? "Asset collision"
561
+ : "Error compiling";
562
+ ui.log.warn(`${errorLabel} ${relativePath}: ${error.message}`);
563
+ }
564
+ }, DEBOUNCE_MS);
457
565
 
458
- debounceMap.set(relativePath, timer);
459
- });
566
+ debounceMap.set(relativePath, timer);
567
+ },
568
+ );
460
569
 
461
570
  themeWatchers.push(watcher);
462
571
  }
package/lib/config.mjs CHANGED
@@ -7,13 +7,42 @@ const CREDENTIALS_FILE = "credentials.json";
7
7
  const THEME_CONFIG_FILE = "tiendu.config.json";
8
8
 
9
9
  /**
10
- * @typedef {{ storeId: number, apiBaseUrl: string, previewKey?: string }} TienduConfig
10
+ * @typedef {{ storeId?: number, apiBaseUrl: string, previewKey?: string }} TienduConfig
11
11
  * @typedef {{ apiKey: string }} TienduCredentials
12
+ * @typedef {{ compileScripts: boolean, compileStyles: boolean, postcss: boolean }} TienduPipelineConfig
13
+ * @typedef {{ pipeline?: Partial<TienduPipelineConfig> }} TienduThemeConfig
12
14
  */
13
15
 
14
16
  const getConfigDir = () => path.resolve(process.cwd(), CONFIG_DIR);
15
17
  const getConfigPath = () => path.join(getConfigDir(), CONFIG_FILE);
16
18
  const getCredentialsPath = () => path.join(getConfigDir(), CREDENTIALS_FILE);
19
+ const getThemeConfigPath = () => path.resolve(process.cwd(), THEME_CONFIG_FILE);
20
+
21
+ const isPlainObject = (value) =>
22
+ value !== null && typeof value === "object" && !Array.isArray(value);
23
+
24
+ /**
25
+ * @param {TienduThemeConfig} themeConfig
26
+ * @returns {TienduThemeConfig}
27
+ */
28
+ const validateThemeConfig = (themeConfig) => {
29
+ if (!isPlainObject(themeConfig)) {
30
+ throw new Error("tiendu.config.json must contain a JSON object.");
31
+ }
32
+
33
+ if (themeConfig.pipeline !== undefined && !isPlainObject(themeConfig.pipeline)) {
34
+ throw new Error('tiendu.config.json: "pipeline" must be an object.');
35
+ }
36
+
37
+ for (const key of ["compileScripts", "compileStyles", "postcss"]) {
38
+ const value = themeConfig.pipeline?.[key];
39
+ if (value !== undefined && typeof value !== "boolean") {
40
+ throw new Error(`tiendu.config.json: "pipeline.${key}" must be true or false.`);
41
+ }
42
+ }
43
+
44
+ return themeConfig;
45
+ };
17
46
 
18
47
  /** @returns {Promise<TienduConfig | null>} */
19
48
  export const readConfig = async () => {
@@ -55,29 +84,49 @@ export const writeCredentials = async (credentials) => {
55
84
  );
56
85
  };
57
86
 
58
- /** @returns {Promise<object | null>} */
87
+ /** @returns {Promise<TienduThemeConfig | null>} */
59
88
  export const readThemeConfig = async () => {
60
89
  try {
61
- const raw = await readFile(
62
- path.resolve(process.cwd(), THEME_CONFIG_FILE),
63
- "utf-8",
64
- );
65
- return JSON.parse(raw);
66
- } catch {
67
- return null;
90
+ const raw = await readFile(getThemeConfigPath(), "utf-8");
91
+
92
+ try {
93
+ return validateThemeConfig(JSON.parse(raw));
94
+ } catch (error) {
95
+ if (error instanceof SyntaxError) {
96
+ throw new Error(`Invalid tiendu.config.json: ${error.message}`);
97
+ }
98
+ throw error;
99
+ }
100
+ } catch (error) {
101
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
102
+ return null;
103
+ }
104
+ throw error;
68
105
  }
69
106
  };
70
107
 
71
- /** @returns {Promise<boolean>} */
72
- export const isBuiltTheme = async () => (await readThemeConfig()) !== null;
108
+ /**
109
+ * @param {TienduThemeConfig | null} themeConfig
110
+ * @returns {TienduPipelineConfig}
111
+ */
112
+ export const getThemePipelineConfig = (themeConfig) => ({
113
+ compileScripts: themeConfig?.pipeline?.compileScripts === true,
114
+ compileStyles: themeConfig?.pipeline?.compileStyles === true,
115
+ postcss: themeConfig?.pipeline?.postcss === true,
116
+ });
117
+
118
+ /** @returns {Promise<TienduPipelineConfig>} */
119
+ export const readThemePipelineConfig = async () =>
120
+ getThemePipelineConfig(await readThemeConfig());
73
121
 
74
122
  /** @returns {string} */
75
123
  export const getDistDir = () => path.resolve(process.cwd(), "dist");
76
124
 
77
125
  /**
126
+ * @param {{ requireStore?: boolean }} [options]
78
127
  * @returns {Promise<{ config: TienduConfig, credentials: TienduCredentials }>}
79
128
  */
80
- export const loadConfigOrFail = async () => {
129
+ export const loadConfigOrFail = async ({ requireStore = true } = {}) => {
81
130
  const config = await readConfig();
82
131
  if (!config) {
83
132
  console.error("Error: no .cli/config.json found. Run tiendu init first.");
@@ -92,5 +141,12 @@ export const loadConfigOrFail = async () => {
92
141
  process.exit(1);
93
142
  }
94
143
 
144
+ if (requireStore && !config.storeId) {
145
+ console.error(
146
+ "Error: no store selected. Run tiendu stores list and tiendu stores set <id>.",
147
+ );
148
+ process.exit(1);
149
+ }
150
+
95
151
  return { config, credentials };
96
152
  };