@withl5e/l5e 0.3.3-alpha.0 → 0.3.3-alpha.2

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.
@@ -155,8 +155,20 @@ export type SchemaMarkup = any | Array<any>;
155
155
  export interface LoaderResult {
156
156
  props?: Record<string, any>;
157
157
  lang?: string;
158
+
159
+ /**
160
+ * Browser, CDN max-age
161
+ */
158
162
  maxAge?: number;
163
+
164
+ /**
165
+ * CDN max-age
166
+ */
159
167
  sMaxAge?: number;
168
+
169
+ /**
170
+ * CDN stale-while-revalidate, browser must revalidate
171
+ */
160
172
  swr?: number;
161
173
  cacheTags?: string[] | Record<string, boolean>;
162
174
  rawResponse?: RawResponse;
@@ -479,10 +491,10 @@ export async function render(
479
491
  const serviceError = isProduction
480
492
  ? new ServiceUnavailableException('Internal Server Error')
481
493
  : new ServiceUnavailableException(err.message || 'Internal Server Error', {
482
- originalError: err.name,
483
- stack: err.stack,
484
- timestamp: new Date().toISOString(),
485
- });
494
+ originalError: err.name,
495
+ stack: err.stack,
496
+ timestamp: new Date().toISOString(),
497
+ });
486
498
  return await renderErrorView(serviceError);
487
499
  }
488
500
  }, requestInfo, undefined, { externalizeIslandProps });
@@ -0,0 +1,35 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export const GLOBAL_STYLE_SOURCE = '/src/global.css';
5
+ export const GLOBAL_STYLE_MANIFEST_KEY = 'src/global.css';
6
+
7
+ export function findGlobalStyleInput(root: string): string | null {
8
+ const absolutePath = join(root, GLOBAL_STYLE_MANIFEST_KEY);
9
+ return existsSync(absolutePath) ? absolutePath : null;
10
+ }
11
+
12
+ export function withAssetBase(base: string, assetPath: string): string {
13
+ const cleanPath = assetPath.replace(/^\/+/, '');
14
+ const normalizedBase = base.endsWith('/') ? base : `${base}/`;
15
+ return `${normalizedBase}${cleanPath}`;
16
+ }
17
+
18
+ export function resolveGlobalStyleHref({
19
+ root,
20
+ manifest,
21
+ isProduction,
22
+ base,
23
+ }: {
24
+ root: string;
25
+ manifest?: Record<string, any>;
26
+ isProduction: boolean;
27
+ base: string;
28
+ }): string | null {
29
+ if (!isProduction) {
30
+ return findGlobalStyleInput(root) ? withAssetBase(base, GLOBAL_STYLE_SOURCE) : null;
31
+ }
32
+
33
+ const file = manifest?.[GLOBAL_STYLE_MANIFEST_KEY]?.file;
34
+ return typeof file === 'string' ? withAssetBase(base, file) : null;
35
+ }
@@ -9,6 +9,7 @@ import type { ViteDevServer } from 'vite';
9
9
  import { createContext, type MiddlewareHandler, type RewritePayload } from '../middleware';
10
10
  import { bundleCss, bundleScripts, getBundledFile } from './bundler';
11
11
  import type { RenderResult, RequestInfo } from './entry-server';
12
+ import { resolveGlobalStyleHref } from './global-style';
12
13
  import { escapeProp } from './render';
13
14
  import { createHeadersFromExpressRequest, parseCookies } from './request';
14
15
 
@@ -213,6 +214,7 @@ async function createPageResponse({
213
214
  root,
214
215
  distClientDir,
215
216
  isProduction,
217
+ assetBase,
216
218
  }: {
217
219
  rendered: RenderResult;
218
220
  template: string;
@@ -220,6 +222,7 @@ async function createPageResponse({
220
222
  root: string;
221
223
  distClientDir: string;
222
224
  isProduction: boolean;
225
+ assetBase: string;
223
226
  }): Promise<globalThis.Response> {
224
227
  const rawResponse = createRawResponse(rendered);
225
228
  if (rawResponse) {
@@ -244,9 +247,23 @@ async function createPageResponse({
244
247
  const swr: number | undefined = rendered.swr;
245
248
 
246
249
  let extraHead = '';
250
+ const emittedStyles = new Set<string>();
251
+ const appendStylesheet = (href: string, crossorigin = false) => {
252
+ if (emittedStyles.has(href)) return;
253
+ emittedStyles.add(href);
254
+ extraHead += `<link rel="stylesheet"${crossorigin ? ' crossorigin' : ''} href="${escapeProp(href)}">`;
255
+ };
247
256
  let globalScripts: string[] = [];
248
257
  let islandRegistryScript = '';
249
258
 
259
+ const globalStyleHref = resolveGlobalStyleHref({
260
+ root,
261
+ manifest,
262
+ isProduction,
263
+ base: assetBase,
264
+ });
265
+ if (globalStyleHref) appendStylesheet(globalStyleHref, isProduction);
266
+
250
267
  // Externalized island props → a single JSON script at the end of the document.
251
268
  // serialize-javascript with isJSON escapes `<`, `>`, `&` (and U+2028/2029) so the
252
269
  // props can't break out of the <script> block or inject markup (XSS). The runtime
@@ -324,7 +341,7 @@ async function createPageResponse({
324
341
  if (globalEntry) {
325
342
  if (globalEntry.css && globalEntry.css.length > 0) {
326
343
  globalEntry.css.forEach((cssFile: string) => {
327
- extraHead += `<link rel="stylesheet" crossorigin href="/${cssFile}">`;
344
+ appendStylesheet(`/${cssFile}`, true);
328
345
  });
329
346
  }
330
347
  if (globalEntry.file) {
@@ -346,18 +363,13 @@ async function createPageResponse({
346
363
  }
347
364
 
348
365
  if (cssSrcList.length > 0) {
349
- extraHead += cssSrcList
350
- .map((file) => `<link rel="stylesheet" crossorigin href="${file}">`)
351
- .join('');
366
+ cssSrcList.forEach((file) => appendStylesheet(file, true));
352
367
  }
353
368
  }
354
369
 
355
- let cssHtml = '';
356
370
  if (!isProduction) {
357
371
  // Registry đã dedupe, nhưng vẫn lọc lại ở đây để dev không bao giờ ra thẻ trùng
358
- cssHtml = [...new Set(cssSrcList)]
359
- .map((src) => `<link rel="stylesheet" href="${src}">`)
360
- .join('');
372
+ [...new Set(cssSrcList)].forEach((src) => appendStylesheet(src));
361
373
  }
362
374
 
363
375
  let allScripts = [...globalScripts, ...scriptSrcList];
@@ -394,21 +406,39 @@ async function createPageResponse({
394
406
  const html = rendered.rawHtml
395
407
  ? rendered.html || ''
396
408
  : templateWithLang
397
- .replace(`<!--app-head-->`, () => (rendered.head ?? '') + extraHead + cssHtml)
398
- .replace(`<!--app-html-->`, () => rendered.html ?? '')
399
- .replace(`<!--app-scripts-->`, () => scriptsHtml);
409
+ .replace(`<!--app-head-->`, () => (rendered.head ?? '') + extraHead)
410
+ .replace(`<!--app-html-->`, () => rendered.html ?? '')
411
+ .replace(`<!--app-scripts-->`, () => scriptsHtml);
400
412
 
401
413
  const headers = new Headers({
402
414
  'Content-Type': 'text/html',
403
415
  });
404
416
 
405
- const cacheControlParts: string[] = ['public'];
406
- if (maxAge !== undefined) cacheControlParts.push(`max-age=${maxAge}`);
407
- if (sMaxAge !== undefined) cacheControlParts.push(`s-maxage=${sMaxAge}`);
408
- if (swr !== undefined) cacheControlParts.push(`stale-while-revalidate=${swr}`);
417
+ const cacheControlParts: string[] = [];
418
+ const cdnCacheControlParts: string[] = [];
419
+ if (maxAge !== undefined) {
420
+ cacheControlParts.push(`max-age=${maxAge}`);
421
+ cdnCacheControlParts.push(`max-age=${maxAge}`);
422
+ }
423
+
424
+ if (sMaxAge !== undefined) {
425
+ cdnCacheControlParts.push(`s-maxage=${sMaxAge}`);
426
+ }
427
+
428
+ // ko cho phép browser swr
429
+ if (swr !== undefined) {
430
+ cdnCacheControlParts.push(`stale-while-revalidate=${swr}`);
431
+ }
409
432
 
410
433
  if (cacheControlParts.length > 1 && isProduction) {
434
+ cacheControlParts.push('public');
435
+ cacheControlParts.push('must-revalidate');
436
+
437
+ cdnCacheControlParts.push('public');
438
+
439
+
411
440
  headers.set('Cache-Control', cacheControlParts.join(', '));
441
+ headers.set('CDN-Cache-Control', cdnCacheControlParts.join(', '));
412
442
  }
413
443
 
414
444
  if (process.env.NODE_ENV === 'production') {
@@ -747,6 +777,7 @@ export async function createServer(options: ServerOptions = {}): Promise<ServerC
747
777
  root,
748
778
  distClientDir,
749
779
  isProduction,
780
+ assetBase: isProduction ? base : vite!.config.base,
750
781
  });
751
782
  };
752
783
 
@@ -10,6 +10,7 @@ import {
10
10
  } from 'fs';
11
11
  import { dirname, join, relative, resolve } from 'path';
12
12
  import type { Plugin, UserConfig } from 'vite';
13
+ import { findGlobalStyleInput } from './global-style';
13
14
 
14
15
  const VIRTUAL_L5E_VIEWS = 'virtual:l5e-views';
15
16
  const VIRTUAL_L5E_ROUTE = 'virtual:l5e-route';
@@ -246,6 +247,12 @@ function discoverRollupInput(rootDir: string): {
246
247
  console.log('[l5e] Detected src/client.global.ts and added as entry');
247
248
  }
248
249
 
250
+ const globalStyleInput = findGlobalStyleInput(rootDir);
251
+ if (globalStyleInput) {
252
+ input['global-style'] = globalStyleInput;
253
+ console.log('[l5e] Detected src/global.css and added as global stylesheet');
254
+ }
255
+
249
256
  // Scan all .tsx and .ts files
250
257
  const tsFiles = scanTsFiles(srcDir);
251
258
 
@@ -297,6 +304,12 @@ function discoverRollupInput(rootDir: string): {
297
304
 
298
305
  const absolutePath = resolve(rootDir, relativePath);
299
306
 
307
+ // src/global.css is already a dedicated convention entry. Keeping one
308
+ // Rollup input avoids collisions if a shared layout also calls useCss().
309
+ if (globalStyleInput && resolve(absolutePath) === resolve(globalStyleInput)) {
310
+ continue;
311
+ }
312
+
300
313
  // Only add if file exists
301
314
  if (!existsSync(absolutePath)) {
302
315
  console.warn(`[l5e] CSS file not found: ${absolutePath} (from ${cssPath})`);