@webstir-io/webstir-frontend 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -0
  3. package/dist/assets/assetManifest.d.ts +16 -0
  4. package/dist/assets/assetManifest.js +31 -0
  5. package/dist/assets/imageOptimizer.d.ts +6 -0
  6. package/dist/assets/imageOptimizer.js +93 -0
  7. package/dist/assets/precompression.d.ts +1 -0
  8. package/dist/assets/precompression.js +21 -0
  9. package/dist/builders/contentBuilder.d.ts +2 -0
  10. package/dist/builders/contentBuilder.js +1052 -0
  11. package/dist/builders/cssBuilder.d.ts +2 -0
  12. package/dist/builders/cssBuilder.js +439 -0
  13. package/dist/builders/htmlBuilder.d.ts +2 -0
  14. package/dist/builders/htmlBuilder.js +430 -0
  15. package/dist/builders/index.d.ts +2 -0
  16. package/dist/builders/index.js +14 -0
  17. package/dist/builders/jsBuilder.d.ts +2 -0
  18. package/dist/builders/jsBuilder.js +300 -0
  19. package/dist/builders/staticAssetsBuilder.d.ts +2 -0
  20. package/dist/builders/staticAssetsBuilder.js +158 -0
  21. package/dist/builders/types.d.ts +12 -0
  22. package/dist/builders/types.js +1 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.js +105 -0
  25. package/dist/config/manifest.d.ts +7 -0
  26. package/dist/config/manifest.js +17 -0
  27. package/dist/config/paths.d.ts +3 -0
  28. package/dist/config/paths.js +11 -0
  29. package/dist/config/schema.d.ts +413 -0
  30. package/dist/config/schema.js +44 -0
  31. package/dist/config/setup.d.ts +2 -0
  32. package/dist/config/setup.js +12 -0
  33. package/dist/config/workspace.d.ts +2 -0
  34. package/dist/config/workspace.js +131 -0
  35. package/dist/config/workspaceManifest.d.ts +23 -0
  36. package/dist/config/workspaceManifest.js +1 -0
  37. package/dist/core/constants.d.ts +70 -0
  38. package/dist/core/constants.js +70 -0
  39. package/dist/core/diagnostics.d.ts +15 -0
  40. package/dist/core/diagnostics.js +21 -0
  41. package/dist/core/index.d.ts +3 -0
  42. package/dist/core/index.js +3 -0
  43. package/dist/core/pages.d.ts +6 -0
  44. package/dist/core/pages.js +23 -0
  45. package/dist/hooks.d.ts +19 -0
  46. package/dist/hooks.js +115 -0
  47. package/dist/html/criticalCss.d.ts +4 -0
  48. package/dist/html/criticalCss.js +192 -0
  49. package/dist/html/htmlSecurity.d.ts +5 -0
  50. package/dist/html/htmlSecurity.js +73 -0
  51. package/dist/html/lazyLoad.d.ts +6 -0
  52. package/dist/html/lazyLoad.js +21 -0
  53. package/dist/html/pageScaffold.d.ts +10 -0
  54. package/dist/html/pageScaffold.js +51 -0
  55. package/dist/html/resourceHints.d.ts +7 -0
  56. package/dist/html/resourceHints.js +64 -0
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.js +5 -0
  59. package/dist/modes/ssg/index.d.ts +4 -0
  60. package/dist/modes/ssg/index.js +4 -0
  61. package/dist/modes/ssg/metadata.d.ts +5 -0
  62. package/dist/modes/ssg/metadata.js +50 -0
  63. package/dist/modes/ssg/routing.d.ts +2 -0
  64. package/dist/modes/ssg/routing.js +186 -0
  65. package/dist/modes/ssg/seo.d.ts +4 -0
  66. package/dist/modes/ssg/seo.js +208 -0
  67. package/dist/modes/ssg/validation.d.ts +3 -0
  68. package/dist/modes/ssg/validation.js +27 -0
  69. package/dist/modes/ssg/views.d.ts +2 -0
  70. package/dist/modes/ssg/views.js +236 -0
  71. package/dist/operations.d.ts +5 -0
  72. package/dist/operations.js +102 -0
  73. package/dist/pipeline.d.ts +7 -0
  74. package/dist/pipeline.js +71 -0
  75. package/dist/provider.d.ts +2 -0
  76. package/dist/provider.js +176 -0
  77. package/dist/types.d.ts +61 -0
  78. package/dist/types.js +1 -0
  79. package/dist/utils/changedFile.d.ts +8 -0
  80. package/dist/utils/changedFile.js +26 -0
  81. package/dist/utils/fs.d.ts +11 -0
  82. package/dist/utils/fs.js +39 -0
  83. package/dist/utils/hash.d.ts +1 -0
  84. package/dist/utils/hash.js +5 -0
  85. package/dist/utils/pagePaths.d.ts +5 -0
  86. package/dist/utils/pagePaths.js +36 -0
  87. package/dist/utils/pathMatch.d.ts +3 -0
  88. package/dist/utils/pathMatch.js +29 -0
  89. package/dist/watch/frontendFiles.d.ts +3 -0
  90. package/dist/watch/frontendFiles.js +25 -0
  91. package/dist/watch/hotUpdateTracker.d.ts +51 -0
  92. package/dist/watch/hotUpdateTracker.js +205 -0
  93. package/dist/watch/pipelineHelpers.d.ts +26 -0
  94. package/dist/watch/pipelineHelpers.js +177 -0
  95. package/dist/watch/types.d.ts +27 -0
  96. package/dist/watch/types.js +1 -0
  97. package/dist/watch/watchCoordinator.d.ts +36 -0
  98. package/dist/watch/watchCoordinator.js +551 -0
  99. package/dist/watch/watchDaemon.d.ts +17 -0
  100. package/dist/watch/watchDaemon.js +127 -0
  101. package/dist/watch/watchReporter.d.ts +21 -0
  102. package/dist/watch/watchReporter.js +64 -0
  103. package/package.json +92 -0
  104. package/scripts/publish.sh +101 -0
  105. package/scripts/smoke.mjs +35 -0
  106. package/scripts/update-contract.sh +121 -0
  107. package/src/assets/assetManifest.ts +51 -0
  108. package/src/assets/imageOptimizer.ts +112 -0
  109. package/src/assets/precompression.ts +25 -0
  110. package/src/builders/contentBuilder.ts +1400 -0
  111. package/src/builders/cssBuilder.ts +552 -0
  112. package/src/builders/htmlBuilder.ts +540 -0
  113. package/src/builders/index.ts +16 -0
  114. package/src/builders/jsBuilder.ts +358 -0
  115. package/src/builders/staticAssetsBuilder.ts +174 -0
  116. package/src/builders/types.ts +15 -0
  117. package/src/cli.ts +108 -0
  118. package/src/config/manifest.ts +24 -0
  119. package/src/config/paths.ts +14 -0
  120. package/src/config/schema.ts +49 -0
  121. package/src/config/setup.ts +14 -0
  122. package/src/config/workspace.ts +150 -0
  123. package/src/config/workspaceManifest.ts +27 -0
  124. package/src/core/constants.ts +73 -0
  125. package/src/core/diagnostics.ts +40 -0
  126. package/src/core/index.ts +3 -0
  127. package/src/core/pages.ts +31 -0
  128. package/src/hooks.ts +175 -0
  129. package/src/html/criticalCss.ts +214 -0
  130. package/src/html/htmlSecurity.ts +86 -0
  131. package/src/html/lazyLoad.ts +30 -0
  132. package/src/html/pageScaffold.ts +70 -0
  133. package/src/html/resourceHints.ts +91 -0
  134. package/src/index.ts +5 -0
  135. package/src/modes/ssg/index.ts +4 -0
  136. package/src/modes/ssg/metadata.ts +63 -0
  137. package/src/modes/ssg/routing.ts +230 -0
  138. package/src/modes/ssg/seo.ts +261 -0
  139. package/src/modes/ssg/validation.ts +37 -0
  140. package/src/modes/ssg/views.ts +309 -0
  141. package/src/operations.ts +138 -0
  142. package/src/pipeline.ts +88 -0
  143. package/src/provider.ts +249 -0
  144. package/src/types.ts +67 -0
  145. package/src/utils/changedFile.ts +39 -0
  146. package/src/utils/fs.ts +48 -0
  147. package/src/utils/hash.ts +6 -0
  148. package/src/utils/pagePaths.ts +43 -0
  149. package/src/utils/pathMatch.ts +36 -0
  150. package/src/watch/frontendFiles.ts +32 -0
  151. package/src/watch/hotUpdateTracker.ts +285 -0
  152. package/src/watch/pipelineHelpers.ts +242 -0
  153. package/src/watch/types.ts +23 -0
  154. package/src/watch/watchCoordinator.ts +666 -0
  155. package/src/watch/watchDaemon.ts +144 -0
  156. package/src/watch/watchReporter.ts +98 -0
  157. package/tests/add-page-defaults.test.js +64 -0
  158. package/tests/content-pages.test.js +81 -0
  159. package/tests/css-app-imports.test.js +64 -0
  160. package/tests/css-page-imports.test.js +100 -0
  161. package/tests/diagnostics.test.js +48 -0
  162. package/tests/features.test.js +63 -0
  163. package/tests/hooks.test.js +71 -0
  164. package/tests/provider.integration.test.js +137 -0
  165. package/tests/ssg-defaults.test.js +201 -0
  166. package/tests/ssg-guardrails.test.js +69 -0
  167. package/tsconfig.json +27 -0
@@ -0,0 +1,552 @@
1
+ import path from 'node:path';
2
+ import postcss from 'postcss';
3
+ import autoprefixer from 'autoprefixer';
4
+ import customMedia from 'postcss-custom-media';
5
+ import * as cssoModule from 'csso';
6
+ import { glob } from 'glob';
7
+ import { FOLDERS, FILES, EXTENSIONS } from '../core/constants.js';
8
+ import { ensureDir, pathExists, readFile, writeFile, remove, copy } from '../utils/fs.js';
9
+ import type { Builder, BuilderContext } from './types.js';
10
+ import { getPages } from '../core/pages.js';
11
+ import { hashContent } from '../utils/hash.js';
12
+ import { updatePageManifest, updateSharedAssets, readSharedAssets } from '../assets/assetManifest.js';
13
+ import { createCompressedVariants } from '../assets/precompression.js';
14
+ import { shouldProcess } from '../utils/changedFile.js';
15
+ import { findPageFromChangedFile } from '../utils/pathMatch.js';
16
+
17
+ const MODULE_SUFFIX = '.module';
18
+ const APP_CSS_BASENAME = 'app';
19
+ const csso = ((cssoModule as unknown as { default?: typeof cssoModule }).default ?? cssoModule) as typeof cssoModule;
20
+ const PAGE_IMPORT_PATTERN = /@import\s+(?:url\()?[\s]*['"]([^'"\)]+)['"][\s]*\)?\s*;?/g;
21
+
22
+ interface SharedCssArtifacts {
23
+ appCss?: string;
24
+ }
25
+
26
+ export function createCssBuilder(context: BuilderContext): Builder {
27
+ return {
28
+ name: 'css',
29
+ async build(): Promise<void> {
30
+ await processCss(context, false);
31
+ },
32
+ async publish(): Promise<void> {
33
+ await processCss(context, true);
34
+ }
35
+ };
36
+ }
37
+
38
+ async function processCss(context: BuilderContext, isProduction: boolean): Promise<void> {
39
+ const { config } = context;
40
+ if (!shouldProcess(context, [
41
+ { directory: config.paths.src.pages, extensions: [EXTENSIONS.css] },
42
+ { directory: config.paths.src.frontend, extensions: [EXTENSIONS.css] }
43
+ ])) {
44
+ return;
45
+ }
46
+
47
+ const processor = createPostcssProcessor();
48
+ const customMediaPrelude = await loadCustomMediaPrelude(config);
49
+ const sharedArtifacts = await processAppCss(config, isProduction, processor, customMediaPrelude);
50
+ const targetPage = findPageFromChangedFile(context.changedFile, config.paths.src.pages);
51
+ const pages = await getPages(config.paths.src.pages);
52
+
53
+ for (const page of pages) {
54
+ if (targetPage && page.name !== targetPage) {
55
+ continue;
56
+ }
57
+ const entryPath = await resolveCssEntry(page.directory);
58
+ if (!entryPath) {
59
+ continue;
60
+ }
61
+
62
+ const css = await readFile(entryPath);
63
+ const inlinedCss = await inlinePageImports(css, page.directory);
64
+ const prepared = applyCustomMediaPrelude(inlinedCss, customMediaPrelude);
65
+ const processed = await processor.process(prepared, { from: entryPath, map: !isProduction ? { inline: true } : false });
66
+ const normalized = resolveAppImports(processed.css, isProduction ? sharedArtifacts.appCss : undefined);
67
+
68
+ if (isProduction) {
69
+ const inlined = await inlineAppImports(normalized, config.paths.dist.frontend);
70
+ await emitProductionCss(config, page.name, inlined);
71
+ } else {
72
+ await emitDevelopmentCss(config, page.name, normalized);
73
+ await syncPageCssAssetsForDevelopment(
74
+ page.directory,
75
+ path.join(config.paths.build.pages, page.name),
76
+ entryPath
77
+ );
78
+ }
79
+ }
80
+ }
81
+
82
+ async function emitDevelopmentCss(config: BuilderContext['config'], pageName: string, css: string): Promise<void> {
83
+ const outputDir = path.join(config.paths.build.pages, pageName);
84
+ await ensureDir(outputDir);
85
+ const outputPath = path.join(outputDir, `${FILES.index}${EXTENSIONS.css}`);
86
+ await writeFile(outputPath, css);
87
+ }
88
+
89
+ async function emitProductionCss(config: BuilderContext['config'], pageName: string, css: string): Promise<void> {
90
+ const minified = csso.minify(css).css;
91
+ const hash = hashContent(minified);
92
+ const fileName = `${FILES.index}-${hash}${EXTENSIONS.css}`;
93
+ const outputDir = path.join(config.paths.dist.pages, pageName);
94
+ await ensureDir(outputDir);
95
+ const outputPath = path.join(outputDir, fileName);
96
+ await writeFile(outputPath, minified);
97
+ if (config.features.precompression) {
98
+ await createCompressedVariants(outputPath);
99
+ } else {
100
+ await Promise.all([
101
+ remove(`${outputPath}${EXTENSIONS.br}`).catch(() => undefined),
102
+ remove(`${outputPath}${EXTENSIONS.gz}`).catch(() => undefined)
103
+ ]);
104
+ }
105
+ await updatePageManifest(outputDir, pageName, (manifest) => {
106
+ manifest.css = fileName;
107
+ });
108
+ }
109
+
110
+ async function syncPageCssAssetsForDevelopment(
111
+ pageDirectory: string,
112
+ outputDir: string,
113
+ entryPath: string
114
+ ): Promise<void> {
115
+ const sourceFiles = await glob('**/*.css', { cwd: pageDirectory, nodir: true });
116
+ const entryRelative = normalizeForwardSlashes(path.relative(pageDirectory, entryPath));
117
+
118
+ const copySet = new Set<string>();
119
+ for (const relative of sourceFiles) {
120
+ const normalized = normalizeForwardSlashes(relative);
121
+ if (normalized === entryRelative) {
122
+ continue;
123
+ }
124
+
125
+ copySet.add(normalized);
126
+ const sourcePath = path.join(pageDirectory, relative);
127
+ const destinationPath = path.join(outputDir, relative);
128
+ await ensureDir(path.dirname(destinationPath));
129
+ await copy(sourcePath, destinationPath);
130
+ }
131
+
132
+ const existingFiles = await glob('**/*.css', { cwd: outputDir, nodir: true });
133
+ for (const relative of existingFiles) {
134
+ const normalized = normalizeForwardSlashes(relative);
135
+ if (normalized === `${FILES.index}${EXTENSIONS.css}`) {
136
+ continue;
137
+ }
138
+
139
+ if (!copySet.has(normalized)) {
140
+ await remove(path.join(outputDir, relative)).catch(() => undefined);
141
+ }
142
+ }
143
+ }
144
+
145
+ async function processAppCss(
146
+ config: BuilderContext['config'],
147
+ isProduction: boolean,
148
+ processor: postcss.Processor,
149
+ customMediaPrelude: string
150
+ ): Promise<SharedCssArtifacts> {
151
+ const appCssPath = path.join(config.paths.src.app, 'app.css');
152
+ if (!(await pathExists(appCssPath))) {
153
+ return {};
154
+ }
155
+
156
+ const source = applyCustomMediaPrelude(await readFile(appCssPath), customMediaPrelude);
157
+
158
+ if (isProduction) {
159
+ const stylesMap = await emitAppStylesProduction(config, processor, customMediaPrelude);
160
+ const processed = await processor.process(source, { from: appCssPath, map: false });
161
+ const rewritten = rewriteAppStyleImports(processed.css, stylesMap);
162
+ const inlined = await inlineAppImports(rewritten, config.paths.dist.frontend);
163
+ const fileName = await emitAppProductionCss(config, inlined);
164
+ await updateSharedAssets(config.paths.dist.frontend, shared => {
165
+ shared.css = fileName;
166
+ });
167
+ return { appCss: fileName };
168
+ }
169
+
170
+ const processed = await processor.process(source, { from: appCssPath, map: { inline: true } });
171
+ const stylesVersion = await computeAppStylesVersion(config.paths.src.app);
172
+ const rewritten = rewriteAppStyleImportsForDevelopment(processed.css, stylesVersion);
173
+ await emitAppDevelopmentCss(config, rewritten);
174
+ await syncAppStyles(config.paths.src.app, path.join(config.paths.build.frontend, FOLDERS.app), processor, customMediaPrelude);
175
+ return {};
176
+ }
177
+
178
+ function createPostcssProcessor(): postcss.Processor {
179
+ return postcss([customMedia(), autoprefixer]);
180
+ }
181
+
182
+ async function loadCustomMediaPrelude(config: BuilderContext['config']): Promise<string> {
183
+ const tokensPath = path.join(config.paths.src.app, 'styles', 'tokens.css');
184
+ if (!(await pathExists(tokensPath))) {
185
+ return '';
186
+ }
187
+
188
+ const contents = await readFile(tokensPath);
189
+ const matches = contents.match(/^[\t ]*@custom-media[^\n]*;[\t ]*$/gm) ?? [];
190
+ if (matches.length === 0) {
191
+ return '';
192
+ }
193
+
194
+ return `${matches.join('\n')}\n`;
195
+ }
196
+
197
+ function applyCustomMediaPrelude(css: string, prelude: string): string {
198
+ if (!prelude) {
199
+ return css;
200
+ }
201
+
202
+ if (!css.includes('@media (--')) {
203
+ return css;
204
+ }
205
+
206
+ if (css.includes('@custom-media')) {
207
+ return css;
208
+ }
209
+
210
+ return `${prelude}${css}`;
211
+ }
212
+
213
+ async function emitAppDevelopmentCss(config: BuilderContext['config'], css: string): Promise<void> {
214
+ const outputDir = path.join(config.paths.build.frontend, FOLDERS.app);
215
+ await ensureDir(outputDir);
216
+ await writeFile(path.join(outputDir, 'app.css'), css);
217
+ }
218
+
219
+ async function emitAppProductionCss(config: BuilderContext['config'], css: string): Promise<string> {
220
+ const { css: stripped, layerOrder } = stripAppLayerOrderStatement(css);
221
+ const minified = restoreAppLayerOrderStatement(csso.minify(stripped).css, layerOrder);
222
+ const hash = hashContent(minified);
223
+ const fileName = `${APP_CSS_BASENAME}-${hash}${EXTENSIONS.css}`;
224
+ const outputDir = path.join(config.paths.dist.frontend, FOLDERS.app);
225
+ await ensureDir(outputDir);
226
+ const outputPath = path.join(outputDir, fileName);
227
+ await writeFile(outputPath, minified);
228
+
229
+ if (config.features.precompression) {
230
+ await createCompressedVariants(outputPath);
231
+ } else {
232
+ await Promise.all([
233
+ remove(`${outputPath}${EXTENSIONS.br}`).catch(() => undefined),
234
+ remove(`${outputPath}${EXTENSIONS.gz}`).catch(() => undefined)
235
+ ]);
236
+ }
237
+
238
+ // Remove previously hashed variants to avoid stale files.
239
+ const existing = await readSharedAssets(config.paths.dist.frontend);
240
+ const previousFile = existing?.css;
241
+ if (previousFile && previousFile !== fileName) {
242
+ const previousPath = path.join(outputDir, previousFile);
243
+ await remove(previousPath).catch(() => undefined);
244
+ await remove(`${previousPath}${EXTENSIONS.br}`).catch(() => undefined);
245
+ await remove(`${previousPath}${EXTENSIONS.gz}`).catch(() => undefined);
246
+ }
247
+
248
+ return fileName;
249
+ }
250
+
251
+ async function syncAppStyles(
252
+ sourceAppDir: string,
253
+ destinationAppDir: string,
254
+ processor: postcss.Processor,
255
+ customMediaPrelude: string
256
+ ): Promise<void> {
257
+ const stylesSource = path.join(sourceAppDir, 'styles');
258
+ if (!(await pathExists(stylesSource))) {
259
+ return;
260
+ }
261
+
262
+ const stylesDestination = path.join(destinationAppDir, 'styles');
263
+ await ensureDir(stylesDestination);
264
+
265
+ const files = await glob('**/*', { cwd: stylesSource, nodir: true });
266
+ for (const relative of files) {
267
+ const sourcePath = path.join(stylesSource, relative);
268
+ const destinationPath = path.join(stylesDestination, relative);
269
+ await ensureDir(path.dirname(destinationPath));
270
+
271
+ if (!relative.endsWith(EXTENSIONS.css)) {
272
+ await copy(sourcePath, destinationPath);
273
+ continue;
274
+ }
275
+
276
+ const source = applyCustomMediaPrelude(await readFile(sourcePath), customMediaPrelude);
277
+ const processed = await processor.process(source, { from: sourcePath, map: { inline: true } });
278
+ await writeFile(destinationPath, processed.css);
279
+ }
280
+ }
281
+
282
+ async function computeAppStylesVersion(sourceAppDir: string): Promise<string> {
283
+ const stylesDir = path.join(sourceAppDir, 'styles');
284
+ if (!(await pathExists(stylesDir))) {
285
+ return 'no-styles';
286
+ }
287
+
288
+ const files = (await glob('**/*.css', { cwd: stylesDir, nodir: true })).sort((a, b) => a.localeCompare(b));
289
+ if (files.length === 0) {
290
+ return 'no-styles';
291
+ }
292
+
293
+ let fingerprint = '';
294
+ for (const relative of files) {
295
+ const contents = await readFile(path.join(stylesDir, relative));
296
+ fingerprint += `${normalizeForwardSlashes(relative)}\0${contents}\0`;
297
+ }
298
+
299
+ return hashContent(fingerprint, 10);
300
+ }
301
+
302
+ function rewriteAppStyleImportsForDevelopment(css: string, stylesVersion: string): string {
303
+ const importPattern = /(@import\s+['"])(?:\.\/)?(styles\/[^'"]+?\.css)(\?v=[^'"]+)?(['"];?)/g;
304
+ return css.replace(importPattern, `$1./$2?v=${stylesVersion}$4`);
305
+ }
306
+
307
+ function resolveAppImports(css: string, appCssFile?: string): string {
308
+ let result = css;
309
+
310
+ if (appCssFile) {
311
+ result = result.replace(/@import\s+['"]@app\/app\.css['"];?/g, `@import "/app/${appCssFile}";`);
312
+ }
313
+
314
+ return result.replace(/@app\//g, '/app/');
315
+ }
316
+
317
+ async function inlinePageImports(css: string, pageDirectory: string, seen: Set<string> = new Set()): Promise<string> {
318
+ const segments: string[] = [];
319
+ let lastIndex = 0;
320
+
321
+ for (const match of css.matchAll(PAGE_IMPORT_PATTERN)) {
322
+ const index = match.index ?? 0;
323
+ segments.push(css.slice(lastIndex, index));
324
+
325
+ const importPath = String(match[1] ?? '').trim();
326
+ if (!shouldInlinePageImport(importPath)) {
327
+ segments.push(match[0]);
328
+ lastIndex = index + match[0].length;
329
+ continue;
330
+ }
331
+
332
+ const resolved = path.resolve(pageDirectory, importPath);
333
+ if (!isWithin(resolved, pageDirectory)) {
334
+ segments.push(match[0]);
335
+ lastIndex = index + match[0].length;
336
+ continue;
337
+ }
338
+
339
+ const key = resolved;
340
+ if (seen.has(key)) {
341
+ lastIndex = index + match[0].length;
342
+ continue;
343
+ }
344
+
345
+ if (!(await pathExists(resolved))) {
346
+ segments.push(match[0]);
347
+ lastIndex = index + match[0].length;
348
+ continue;
349
+ }
350
+
351
+ seen.add(key);
352
+ const imported = await readFile(resolved);
353
+ const inlined = await inlinePageImports(imported, pageDirectory, seen);
354
+ seen.delete(key);
355
+ segments.push(inlined);
356
+
357
+ lastIndex = index + match[0].length;
358
+ }
359
+
360
+ segments.push(css.slice(lastIndex));
361
+ return segments.join('');
362
+ }
363
+
364
+ function shouldInlinePageImport(importPath: string): boolean {
365
+ if (importPath.length === 0) {
366
+ return false;
367
+ }
368
+
369
+ if (!importPath.endsWith(EXTENSIONS.css)) {
370
+ return false;
371
+ }
372
+
373
+ if (importPath.startsWith('/') || importPath.startsWith('http:') || importPath.startsWith('https:')) {
374
+ return false;
375
+ }
376
+
377
+ if (importPath.startsWith('@') || importPath.includes('?') || importPath.includes('#')) {
378
+ return false;
379
+ }
380
+
381
+ if (importPath.includes('..')) {
382
+ return false;
383
+ }
384
+
385
+ return true;
386
+ }
387
+
388
+ function isWithin(candidate: string, root: string): boolean {
389
+ const relative = path.relative(root, candidate);
390
+ return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
391
+ }
392
+
393
+ async function inlineAppImports(css: string, distRoot: string, seen: Set<string> = new Set()): Promise<string> {
394
+ const importPattern = /@import\s+(?:url\()?[\s]*['"]\/app\/([^'"\)]+)['"][\s]*\)?;?/g;
395
+ const segments: string[] = [];
396
+ let lastIndex = 0;
397
+
398
+ for (const match of css.matchAll(importPattern)) {
399
+ const index = match.index ?? 0;
400
+ segments.push(css.slice(lastIndex, index));
401
+
402
+ const relative = normalizeForwardSlashes(match[1] ?? '');
403
+ const inlined = await inlineAppImport(relative, distRoot, seen);
404
+ if (inlined !== null) {
405
+ segments.push(inlined);
406
+ } else {
407
+ segments.push(match[0]);
408
+ }
409
+
410
+ lastIndex = index + match[0].length;
411
+ }
412
+
413
+ segments.push(css.slice(lastIndex));
414
+ return segments.join('');
415
+ }
416
+
417
+ async function inlineAppImport(relativePath: string, distRoot: string, seen: Set<string>): Promise<string | null> {
418
+ if (relativePath.length === 0 || relativePath.includes('..')) {
419
+ return null;
420
+ }
421
+
422
+ const resolved = path.join(distRoot, FOLDERS.app, relativePath);
423
+ if (!(await pathExists(resolved))) {
424
+ return null;
425
+ }
426
+
427
+ const key = resolved;
428
+ if (seen.has(key)) {
429
+ return '';
430
+ }
431
+
432
+ seen.add(key);
433
+ const content = await readFile(resolved);
434
+ const inlined = await inlineAppImports(content, distRoot, seen);
435
+ seen.delete(key);
436
+
437
+ return inlined;
438
+ }
439
+
440
+ async function emitAppStylesProduction(
441
+ config: BuilderContext['config'],
442
+ processor: postcss.Processor,
443
+ customMediaPrelude: string
444
+ ): Promise<Map<string, string>> {
445
+ const sourceDir = path.join(config.paths.src.app, 'styles');
446
+ const mapping = new Map<string, string>();
447
+
448
+ if (!(await pathExists(sourceDir))) {
449
+ const destinationDir = path.join(config.paths.dist.frontend, FOLDERS.app, 'styles');
450
+ await remove(destinationDir).catch(() => undefined);
451
+ return mapping;
452
+ }
453
+
454
+ const destinationDir = path.join(config.paths.dist.frontend, FOLDERS.app, 'styles');
455
+ await remove(destinationDir).catch(() => undefined);
456
+
457
+ const files = await glob('**/*.css', { cwd: sourceDir, nodir: true });
458
+ for (const relative of files) {
459
+ const sourcePath = path.join(sourceDir, relative);
460
+ const source = applyCustomMediaPrelude(await readFile(sourcePath), customMediaPrelude);
461
+ const processed = await processor.process(source, { from: sourcePath, map: false });
462
+ const minified = csso.minify(processed.css).css;
463
+ const hash = hashContent(minified);
464
+ const parsed = path.parse(relative);
465
+ const hashedName = `${parsed.name}-${hash}${EXTENSIONS.css}`;
466
+ const relativeHashedPath = parsed.dir ? path.join(parsed.dir, hashedName) : hashedName;
467
+ const destinationPath = path.join(destinationDir, relativeHashedPath);
468
+ await ensureDir(path.dirname(destinationPath));
469
+ await writeFile(destinationPath, minified);
470
+
471
+ if (config.features.precompression) {
472
+ await createCompressedVariants(destinationPath);
473
+ } else {
474
+ await Promise.all([
475
+ remove(`${destinationPath}${EXTENSIONS.br}`).catch(() => undefined),
476
+ remove(`${destinationPath}${EXTENSIONS.gz}`).catch(() => undefined)
477
+ ]);
478
+ }
479
+
480
+ mapping.set(normalizeForwardSlashes(relative), normalizeForwardSlashes(path.join('styles', relativeHashedPath)));
481
+ }
482
+
483
+ return mapping;
484
+ }
485
+
486
+ function rewriteAppStyleImports(css: string, stylesMap: Map<string, string>): string {
487
+ if (stylesMap.size === 0) {
488
+ return css;
489
+ }
490
+
491
+ let result = css;
492
+ for (const [original, hashed] of stylesMap.entries()) {
493
+ const normalizedOriginal = original.startsWith('styles/') ? original : `styles/${original}`;
494
+ const escaped = escapeRegExp(normalizedOriginal);
495
+ const pattern = new RegExp(`(@import\\s+['"])(?:\.\/)?${escaped}(['"];?)`, 'g');
496
+ result = result.replace(pattern, `$1/app/${hashed}$2`);
497
+ }
498
+
499
+ return result;
500
+ }
501
+
502
+ function normalizeForwardSlashes(value: string): string {
503
+ return value.replace(/\\/g, '/');
504
+ }
505
+
506
+ function escapeRegExp(value: string): string {
507
+ return value.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
508
+ }
509
+
510
+ function stripAppLayerOrderStatement(css: string): { css: string; layerOrder?: string } {
511
+ const layerMatch = css.match(/@layer[^;]*;/);
512
+ if (!layerMatch || layerMatch.index === undefined) {
513
+ return { css };
514
+ }
515
+
516
+ const layerText = layerMatch[0];
517
+ if (layerText.includes('{')) {
518
+ return { css };
519
+ }
520
+
521
+ const withoutLayer = css.slice(0, layerMatch.index) + css.slice(layerMatch.index + layerText.length);
522
+ return { css: withoutLayer, layerOrder: layerText.trim() };
523
+ }
524
+
525
+ function restoreAppLayerOrderStatement(css: string, layerOrder?: string): string {
526
+ if (!layerOrder) {
527
+ return css;
528
+ }
529
+
530
+ const charsetMatch = css.match(/^@charset[^;]*;/);
531
+ if (charsetMatch && charsetMatch.index === 0) {
532
+ const charsetText = charsetMatch[0];
533
+ const rest = css.slice(charsetText.length);
534
+ return `${charsetText}${layerOrder}${rest}`;
535
+ }
536
+
537
+ return `${layerOrder}${css}`;
538
+ }
539
+
540
+ async function resolveCssEntry(pageDirectory: string): Promise<string | null> {
541
+ const modulePath = path.join(pageDirectory, `${FILES.index}${MODULE_SUFFIX}${EXTENSIONS.css}`);
542
+ if (await pathExists(modulePath)) {
543
+ return modulePath;
544
+ }
545
+
546
+ const plainPath = path.join(pageDirectory, `${FILES.index}${EXTENSIONS.css}`);
547
+ if (await pathExists(plainPath)) {
548
+ return plainPath;
549
+ }
550
+
551
+ return null;
552
+ }