tiendu 0.4.0 → 0.5.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
@@ -1,18 +1,32 @@
1
1
  import { watch } from "node:fs";
2
2
  import {
3
- cp,
4
3
  mkdir,
5
4
  readdir,
5
+ readFile,
6
6
  rm,
7
7
  stat,
8
8
  copyFile,
9
+ writeFile,
9
10
  } from "node:fs/promises";
10
11
  import path from "node:path";
11
12
  import * as esbuild from "esbuild";
12
13
  import * as p from "@clack/prompts";
13
14
  import { readThemeConfig } from "./config.mjs";
15
+ import {
16
+ flattenAssetLogicalPath,
17
+ getAssetImportFilter,
18
+ getAssetSourceInfo,
19
+ getStaticAssetSourceDirs,
20
+ syncSingleStaticAsset,
21
+ syncStaticAssets,
22
+ } from "./assets.mjs";
23
+ import { listFilesRecursive } from "./fs-utils.mjs";
24
+ import { createCssPostCssPlugin } from "./postcss.mjs";
14
25
 
15
- const THEME_DIRS = ["layout", "templates", "snippets", "assets"];
26
+ const THEME_SOURCE_OUTPUT_DIRS = ["layout", "templates", "snippets"];
27
+ const LIQUID_LIKE_EXTENSIONS = new Set([".liquid", ".html", ".htm"]);
28
+ 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;
16
30
 
17
31
  /**
18
32
  * Discover JS/TS and CSS entry points from src/layout and src/templates.
@@ -51,31 +65,142 @@ const discoverEntryPoints = async (srcDir) => {
51
65
  };
52
66
 
53
67
  /**
54
- * Copy theme directories (layout/, templates/, snippets/, assets/) to dist/.
68
+ * @param {string} rootDir
69
+ * @param {string} relativeDir
70
+ * @returns {Promise<boolean>}
55
71
  */
56
- const copyThemeFiles = async (rootDir, distDir) => {
57
- for (const dir of THEME_DIRS) {
58
- const src = path.join(rootDir, dir);
59
- const dest = path.join(distDir, dir);
60
- try {
61
- await stat(src);
62
- } catch {
63
- continue;
72
+ const directoryExists = async (rootDir, relativeDir) => {
73
+ try {
74
+ const info = await stat(path.join(rootDir, relativeDir));
75
+ return info.isDirectory();
76
+ } catch {
77
+ return false;
78
+ }
79
+ };
80
+
81
+ const getThemeSourceDirs = async (rootDir) => {
82
+ const resolvedDirs = [];
83
+
84
+ for (const outputRelativeDir of THEME_SOURCE_OUTPUT_DIRS) {
85
+ const sourceCandidates = [`src/${outputRelativeDir}`, outputRelativeDir];
86
+
87
+ for (const sourceRelativeDir of sourceCandidates) {
88
+ if (!(await directoryExists(rootDir, sourceRelativeDir))) continue;
89
+
90
+ resolvedDirs.push({ sourceRelativeDir, outputRelativeDir });
91
+ break;
64
92
  }
65
- await cp(src, dest, { recursive: true });
66
93
  }
94
+
95
+ return resolvedDirs;
67
96
  };
68
97
 
69
- /**
70
- * Copy a single file from root to dist, preserving relative path.
71
- */
72
- const copySingleFile = async (rootDir, distDir, relativePath) => {
73
- const src = path.join(rootDir, relativePath);
74
- const dest = path.join(distDir, relativePath);
98
+ const rewriteDirectAssetPaths = (source, knownAssetLogicalPaths) =>
99
+ source.replace(NESTED_ASSET_PATH_PATTERN, (match, assetPath, suffix = "") => {
100
+ if (!knownAssetLogicalPaths.has(assetPath)) {
101
+ return match;
102
+ }
103
+
104
+ const flattened = flattenAssetLogicalPath(assetPath);
105
+ return flattened ? `/assets/${flattened}${suffix}` : match;
106
+ });
107
+
108
+ const shouldCopyThemeSourceFile = (sourceRelativePath) => {
109
+ const extension = path.extname(sourceRelativePath).toLowerCase();
110
+ return !ENTRY_SOURCE_EXTENSIONS.has(extension);
111
+ };
112
+
113
+ const copyThemeSourceFile = async (
114
+ rootDir,
115
+ distDir,
116
+ sourceRelativePath,
117
+ outputRelativePath,
118
+ knownAssetLogicalPaths,
119
+ ) => {
120
+ const src = path.join(rootDir, sourceRelativePath);
121
+ const dest = path.join(distDir, outputRelativePath);
75
122
  await mkdir(path.dirname(dest), { recursive: true });
123
+
124
+ if (LIQUID_LIKE_EXTENSIONS.has(path.extname(src).toLowerCase())) {
125
+ const source = await readFile(src, "utf-8");
126
+ await writeFile(dest, rewriteDirectAssetPaths(source, knownAssetLogicalPaths), "utf-8");
127
+ return;
128
+ }
129
+
76
130
  await copyFile(src, dest);
77
131
  };
78
132
 
133
+ const copyThemeFiles = async (rootDir, distDir, themeSourceDirs, knownAssetLogicalPaths) => {
134
+ for (const sourceDir of themeSourceDirs) {
135
+ const absoluteSourceDir = path.join(rootDir, sourceDir.sourceRelativeDir);
136
+ const absoluteFiles = await listFilesRecursive(absoluteSourceDir);
137
+
138
+ for (const absolutePath of absoluteFiles) {
139
+ const nestedRelativePath = path.relative(absoluteSourceDir, absolutePath);
140
+ const sourceRelativePath = path.join(sourceDir.sourceRelativeDir, nestedRelativePath);
141
+ const outputRelativePath = path.join(sourceDir.outputRelativeDir, nestedRelativePath);
142
+ if (!shouldCopyThemeSourceFile(sourceRelativePath)) continue;
143
+ await copyThemeSourceFile(
144
+ rootDir,
145
+ distDir,
146
+ sourceRelativePath,
147
+ outputRelativePath,
148
+ knownAssetLogicalPaths,
149
+ );
150
+ }
151
+ }
152
+ };
153
+
154
+ const shouldTriggerTailwindCssRebuild = (relativePath) => {
155
+ const normalizedPath = relativePath.split(path.sep).join("/");
156
+ const extension = path.extname(normalizedPath).toLowerCase();
157
+
158
+ if (!["layout/", "templates/", "snippets/"].some((prefix) => normalizedPath.startsWith(prefix))) {
159
+ return false;
160
+ }
161
+
162
+ return [".liquid", ".html", ".htm", ".js", ".ts", ".json", ".md"].includes(extension);
163
+ };
164
+
165
+ const createSourceAssetImportPlugin = (rootDir, sourceDirs) => ({
166
+ name: "tiendu-source-assets",
167
+ setup(build) {
168
+ build.onResolve({ filter: getAssetImportFilter() }, async (args) => {
169
+ if (!args.path.startsWith(".") && !args.path.startsWith("/")) {
170
+ return null;
171
+ }
172
+
173
+ const candidatePath = path.isAbsolute(args.path)
174
+ ? args.path
175
+ : path.resolve(args.resolveDir, args.path);
176
+ const assetInfo = await getAssetSourceInfo(rootDir, candidatePath, sourceDirs);
177
+ if (!assetInfo) return null;
178
+
179
+ return {
180
+ path: assetInfo.absolutePath,
181
+ namespace: "tiendu-source-asset",
182
+ pluginData: assetInfo,
183
+ };
184
+ });
185
+
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
+ });
194
+ },
195
+ });
196
+
197
+ const runEntryBuilds = async (jsBuildOptions, cssBuildOptions) => {
198
+ const builds = [];
199
+ if (jsBuildOptions) builds.push(esbuild.build(jsBuildOptions));
200
+ if (cssBuildOptions) builds.push(esbuild.build(cssBuildOptions));
201
+ await Promise.all(builds);
202
+ };
203
+
79
204
  /**
80
205
  * Run a one-shot build or start watch mode.
81
206
  * @param {{ watch?: boolean }} options
@@ -96,14 +221,52 @@ export const build = async ({ watch: watchMode = false } = {}) => {
96
221
  await rm(distDir, { recursive: true, force: true });
97
222
  await mkdir(distDir, { recursive: true });
98
223
 
99
- // Copy theme files first
100
- await copyThemeFiles(rootDir, distDir);
224
+ const themeSourceDirs = await getThemeSourceDirs(rootDir);
225
+ const staticAssetSourceDirs = await getStaticAssetSourceDirs(rootDir, { refresh: true });
101
226
 
102
227
  // Discover entry points (JS and CSS separately to avoid key collisions)
103
228
  const { jsEntries, cssEntries } = await discoverEntryPoints(srcDir);
104
229
  const jsCount = Object.keys(jsEntries).length;
105
230
  const cssCount = Object.keys(cssEntries).length;
106
231
  const entryCount = jsCount + cssCount;
232
+ const reservedOutputPaths = new Set([
233
+ ...Object.keys(jsEntries).map((key) => `assets/${key}.js`),
234
+ ...Object.keys(cssEntries).map((key) => `assets/${key}.css`),
235
+ ]);
236
+ let staticAssetOwners = new Map();
237
+ const knownAssetLogicalPaths = new Set();
238
+ const cssPlugins = [];
239
+ const jsPlugins = [createSourceAssetImportPlugin(rootDir, staticAssetSourceDirs)];
240
+
241
+ try {
242
+ staticAssetOwners = await syncStaticAssets(
243
+ rootDir,
244
+ distDir,
245
+ reservedOutputPaths,
246
+ staticAssetSourceDirs,
247
+ );
248
+ } catch (error) {
249
+ p.log.error(`Static asset build failed: ${error.message}`);
250
+ return { ok: false };
251
+ }
252
+
253
+ for (const logicalPath of staticAssetOwners.values()) {
254
+ knownAssetLogicalPaths.add(logicalPath);
255
+ }
256
+
257
+ // Copy theme files after asset paths are known
258
+ await copyThemeFiles(rootDir, distDir, themeSourceDirs, knownAssetLogicalPaths);
259
+
260
+ if (cssCount > 0) {
261
+ try {
262
+ cssPlugins.push(
263
+ await createCssPostCssPlugin(rootDir, { sourceDirs: staticAssetSourceDirs }),
264
+ );
265
+ } catch (error) {
266
+ p.log.error(`CSS pipeline failed to initialize: ${error.message}`);
267
+ return { ok: false };
268
+ }
269
+ }
107
270
 
108
271
  if (entryCount === 0) {
109
272
  p.log.warn("No entry points found in src/layout or src/templates.");
@@ -112,19 +275,16 @@ export const build = async ({ watch: watchMode = false } = {}) => {
112
275
 
113
276
  const outdir = path.join(distDir, "assets");
114
277
  const jsBuildOptions = jsCount > 0
115
- ? { entryPoints: jsEntries, bundle: true, format: "esm", target: "es2020", outdir, logLevel: "warning", write: true }
278
+ ? { entryPoints: jsEntries, bundle: true, format: "esm", target: "es2020", outdir, logLevel: "warning", write: true, plugins: jsPlugins }
116
279
  : null;
117
280
  const cssBuildOptions = cssCount > 0
118
- ? { entryPoints: cssEntries, bundle: true, outdir, logLevel: "warning", write: true }
281
+ ? { entryPoints: cssEntries, bundle: true, outdir, logLevel: "warning", write: true, plugins: cssPlugins }
119
282
  : null;
120
283
 
121
284
  if (!watchMode) {
122
285
  // One-shot build
123
286
  try {
124
- const builds = [];
125
- if (jsBuildOptions) builds.push(esbuild.build(jsBuildOptions));
126
- if (cssBuildOptions) builds.push(esbuild.build(cssBuildOptions));
127
- await Promise.all(builds);
287
+ await runEntryBuilds(jsBuildOptions, cssBuildOptions);
128
288
  p.log.success(
129
289
  `Built ${entryCount} entry point${entryCount === 1 ? "" : "s"} to dist/`,
130
290
  );
@@ -137,14 +297,17 @@ export const build = async ({ watch: watchMode = false } = {}) => {
137
297
 
138
298
  // Watch mode — create contexts for both JS and CSS
139
299
  const contexts = [];
300
+ let cssCtx = null;
140
301
  try {
302
+ await runEntryBuilds(jsBuildOptions, cssBuildOptions);
303
+
141
304
  if (jsBuildOptions) {
142
305
  const jsCtx = await esbuild.context(jsBuildOptions);
143
306
  await jsCtx.watch();
144
307
  contexts.push(jsCtx);
145
308
  }
146
309
  if (cssBuildOptions) {
147
- const cssCtx = await esbuild.context(cssBuildOptions);
310
+ cssCtx = await esbuild.context(cssBuildOptions);
148
311
  await cssCtx.watch();
149
312
  contexts.push(cssCtx);
150
313
  }
@@ -162,39 +325,133 @@ export const build = async ({ watch: watchMode = false } = {}) => {
162
325
  const themeWatchers = [];
163
326
  const debounceMap = new Map();
164
327
  const DEBOUNCE_MS = 200;
328
+ let cssRebuildTimer = null;
329
+ let cssRebuildInFlight = false;
330
+ let cssRebuildQueued = false;
331
+
332
+ const runCssRebuild = async () => {
333
+ if (!cssCtx) return;
334
+ if (cssRebuildInFlight) {
335
+ cssRebuildQueued = true;
336
+ return;
337
+ }
338
+
339
+ cssRebuildInFlight = true;
165
340
 
166
- for (const dir of THEME_DIRS) {
167
- const dirPath = path.join(rootDir, dir);
168
341
  try {
169
- await stat(dirPath);
170
- } catch {
171
- continue;
342
+ await cssCtx.rebuild();
343
+ console.log("⟳ CSS bundles");
344
+ } catch (error) {
345
+ p.log.warn(`Error rebuilding CSS: ${error.message}`);
346
+ } finally {
347
+ cssRebuildInFlight = false;
348
+ if (cssRebuildQueued) {
349
+ cssRebuildQueued = false;
350
+ queueCssRebuild();
351
+ }
352
+ }
353
+ };
354
+
355
+ const queueCssRebuild = () => {
356
+ if (!cssCtx) return;
357
+ if (cssRebuildTimer) clearTimeout(cssRebuildTimer);
358
+
359
+ cssRebuildTimer = setTimeout(() => {
360
+ cssRebuildTimer = null;
361
+ void runCssRebuild();
362
+ }, DEBOUNCE_MS);
363
+ };
364
+
365
+ const handleStaticAssetChange = async (relativePath) => {
366
+ const result = await syncSingleStaticAsset(
367
+ rootDir,
368
+ distDir,
369
+ relativePath,
370
+ reservedOutputPaths,
371
+ staticAssetOwners,
372
+ staticAssetSourceDirs,
373
+ );
374
+
375
+ if (!result) return;
376
+
377
+ if (result.type === "copy") {
378
+ knownAssetLogicalPaths.add(result.logicalPath);
379
+ } else {
380
+ knownAssetLogicalPaths.delete(result.logicalPath);
172
381
  }
173
382
 
383
+ console.log(`${result.type === "delete" ? "✕" : "⟳"} ${result.outputRelativePath}`);
384
+ };
385
+
386
+ for (const sourceDir of themeSourceDirs) {
387
+ const dirPath = path.join(rootDir, sourceDir.sourceRelativeDir);
388
+
174
389
  const watcher = watch(dirPath, { recursive: true }, (eventType, filename) => {
175
390
  if (!filename) return;
176
- const relativePath = path.join(dir, filename);
391
+ const normalizedFilename = filename.split(path.sep).join("/");
392
+ const sourceRelativePath = `${sourceDir.sourceRelativeDir}/${normalizedFilename}`;
393
+ const outputRelativePath = `${sourceDir.outputRelativeDir}/${normalizedFilename}`;
177
394
 
178
- const existing = debounceMap.get(relativePath);
395
+ const existing = debounceMap.get(sourceRelativePath);
179
396
  if (existing) clearTimeout(existing);
180
397
 
181
398
  const timer = setTimeout(async () => {
182
- debounceMap.delete(relativePath);
399
+ debounceMap.delete(sourceRelativePath);
183
400
  try {
184
- const fileStat = await stat(path.join(rootDir, relativePath)).catch(
401
+ if (!shouldCopyThemeSourceFile(sourceRelativePath)) {
402
+ return;
403
+ }
404
+
405
+ const fileStat = await stat(path.join(rootDir, sourceRelativePath)).catch(
185
406
  () => null,
186
407
  );
187
408
  if (fileStat && fileStat.isFile()) {
188
- await copySingleFile(rootDir, distDir, relativePath);
189
- console.log(`⟳ ${relativePath}`);
409
+ await copyThemeSourceFile(
410
+ rootDir,
411
+ distDir,
412
+ sourceRelativePath,
413
+ outputRelativePath,
414
+ knownAssetLogicalPaths,
415
+ );
416
+ console.log(`⟳ ${outputRelativePath}`);
190
417
  } else if (!fileStat) {
191
418
  // File deleted — remove from dist
192
- const dest = path.join(distDir, relativePath);
419
+ const dest = path.join(distDir, outputRelativePath);
193
420
  await rm(dest, { force: true });
194
- console.log(`✕ ${relativePath}`);
421
+ console.log(`✕ ${outputRelativePath}`);
422
+ }
423
+
424
+ if (shouldTriggerTailwindCssRebuild(outputRelativePath)) {
425
+ queueCssRebuild();
195
426
  }
196
427
  } catch (error) {
197
- p.log.warn(`Error copying ${relativePath}: ${error.message}`);
428
+ p.log.warn(`Error copying ${sourceRelativePath}: ${error.message}`);
429
+ }
430
+ }, DEBOUNCE_MS);
431
+
432
+ debounceMap.set(sourceRelativePath, timer);
433
+ });
434
+
435
+ themeWatchers.push(watcher);
436
+ }
437
+
438
+ 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}`);
198
455
  }
199
456
  }, DEBOUNCE_MS);
200
457
 
@@ -207,6 +464,7 @@ export const build = async ({ watch: watchMode = false } = {}) => {
207
464
  const cleanup = async () => {
208
465
  for (const w of themeWatchers) w.close();
209
466
  for (const timer of debounceMap.values()) clearTimeout(timer);
467
+ if (cssRebuildTimer) clearTimeout(cssRebuildTimer);
210
468
  for (const ctx of contexts) await ctx.dispose();
211
469
  };
212
470