bosia 1.1.6 → 1.2.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/ambient.d.ts +3 -0
  5. package/src/cli/sync.ts +14 -1
  6. package/src/core/appHtml.ts +52 -0
  7. package/src/core/build.ts +4 -3
  8. package/src/core/client/App.svelte +82 -12
  9. package/src/core/client/TitleProbe.svelte +15 -0
  10. package/src/core/client/router.svelte.ts +1 -2
  11. package/src/core/client/titleMemory.svelte.ts +64 -0
  12. package/src/core/headScan.ts +132 -0
  13. package/src/core/html.ts +103 -45
  14. package/src/core/renderer.ts +190 -91
  15. package/src/core/routeFile.ts +15 -2
  16. package/src/core/scanner.ts +28 -31
  17. package/src/core/server.ts +95 -80
  18. package/src/lib/utils.ts +1 -8
  19. package/templates/default/package.json +2 -2
  20. package/templates/demo/package.json +2 -2
  21. package/templates/shop/package.json +2 -2
  22. package/templates/shop/src/routes/(private)/dashboard/files/+page.svelte +9 -0
  23. package/templates/shop/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
  24. package/templates/shop/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
  25. package/templates/shop/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
  26. package/templates/shop/src/routes/(private)/dashboard/products/+page.svelte +9 -0
  27. package/templates/shop/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
  28. package/templates/shop/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
  29. package/templates/shop/src/routes/(private)/dashboard/users/+page.svelte +9 -0
  30. package/templates/shop/src/routes/(public)/+page.svelte +2 -2
  31. package/templates/store/package.json +2 -2
  32. package/templates/store/src/routes/(private)/dashboard/files/+page.svelte +9 -0
  33. package/templates/store/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
  34. package/templates/store/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
  35. package/templates/store/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
  36. package/templates/store/src/routes/(private)/dashboard/products/+page.svelte +9 -0
  37. package/templates/store/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
  38. package/templates/store/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
  39. package/templates/store/src/routes/(private)/dashboard/users/+page.svelte +9 -0
  40. package/templates/store/src/routes/(public)/+page.svelte +2 -2
package/src/core/html.ts CHANGED
@@ -5,8 +5,9 @@ import { nonceAttr } from "./csp.ts";
5
5
  import { rebaseHtmlAttrs } from "./basePath.ts";
6
6
  import { currentBase } from "./appBase.ts";
7
7
  import type { AppHtmlSegments } from "./appHtml.ts";
8
- import { interpolateSegment } from "./appHtml.ts";
8
+ import { headSplit, interpolateSegment } from "./appHtml.ts";
9
9
  import type { Metadata } from "./hooks.ts";
10
+ import { DEFAULT_TITLE, hasTitle } from "./headScan.ts";
10
11
 
11
12
  // Workers compresses any body sent with a Content-Encoding header — again, if it
12
13
  // already is — unless told the bytes are final. Bun ignores the key.
@@ -249,6 +250,8 @@ export function buildHtml(
249
250
  segments?: AppHtmlSegments,
250
251
  metadata?: Metadata | null,
251
252
  pattern?: string,
253
+ headExtras?: string[],
254
+ fallbackTitle = DEFAULT_TITLE,
252
255
  ): string {
253
256
  // An app writes <a href="/masuk">; under a base the browser has to be handed
254
257
  // /sso/masuk or it walks off this app entirely. Only the rendered markup is
@@ -258,11 +261,11 @@ export function buildHtml(
258
261
  head = rebaseHtmlAttrs(B, head);
259
262
 
260
263
  // Metadata goes in before `head`: the first <title> in the document wins, and
261
- // the streaming path already puts metadata() ahead of <svelte:head> content
262
- // (which arrives later via buildHtmlTail). Same order = same winner on both paths.
264
+ // buildMetadataChunk uses the same order on the streaming path, so both paths
265
+ // pick the same winner.
263
266
  const metaTags = rebaseHtmlAttrs(B, metadataTags(metadata ?? null));
264
- const fallbackTitle =
265
- metaTags.includes("<title>") || head.includes("<title>") ? "" : "<title>Bosia App</title>";
267
+ const extras = rebaseHtmlAttrs(B, headExtraTags(headExtras));
268
+ const fallback = titleFallback(segments, metadata, fallbackTitle, extras, head);
266
269
 
267
270
  const n = nonceAttr(nonce);
268
271
  const publicEnv = getPublicDynamicEnv();
@@ -271,14 +274,8 @@ export function buildHtml(
271
274
  ? `\n <script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
272
275
  : "";
273
276
 
274
- const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
275
-
276
- const depsScript =
277
- pageDeps !== null || layoutDeps !== null
278
- ? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
279
- : "";
280
-
281
- const sysScript = ssrFlag || depsScript ? `\n <script${n}>${ssrFlag}${depsScript}</script>` : "";
277
+ const sys = sysScriptBody(ssr, metadata?.title, pageDeps, layoutDeps);
278
+ const sysScript = sys ? `\n <script${n}>${sys}</script>` : "";
282
279
 
283
280
  const dataIslands = csr
284
281
  ? `\n <script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>` +
@@ -307,7 +304,6 @@ export function buildHtml(
307
304
  lang: safeKey,
308
305
  nonce,
309
306
  });
310
- const headCloseInterpolated = interpolateSegment(segments.headClose, { nonce });
311
307
  const tailInterpolated = interpolateSegment(segments.tail, { nonce });
312
308
  const faviconLine = segments.hasCustomFavicon
313
309
  ? ""
@@ -319,8 +315,8 @@ export function buildHtml(
319
315
  componentCssLinks() +
320
316
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
321
317
  preloads +
322
- ` ${fallbackTitle}${metaTags}${head}` +
323
- headCloseInterpolated +
318
+ ` ${metaTags}${extras}` +
319
+ closeHead(segments, lateHead(head, fallback), nonce) +
324
320
  (body ? "" : `\n${SPINNER}`) +
325
321
  `\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
326
322
  tailInterpolated
@@ -332,10 +328,8 @@ export function buildHtml(
332
328
  <head>
333
329
  <meta charset="UTF-8">
334
330
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
335
- ${fallbackTitle}
336
331
  <link rel="icon" type="image/svg+xml" href="${FAVICON}">
337
- ${metaTags} ${head}
338
- ${twCssLink()}
332
+ ${metaTags}${extras}${lateHead(head, fallback)} ${twCssLink()}
339
333
  ${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
340
334
  ${preloads}</head>
341
335
  <body>
@@ -424,29 +418,86 @@ export function metadataTags(metadata: Metadata | null): string {
424
418
  return out;
425
419
  }
426
420
 
421
+ /** app.html's headClose with `late` inserted just before its `</head>`, after any
422
+ * markup the app put after %bosia.head%, so <svelte:head> stays last in <head>
423
+ * and a page's styles and meta win over app.html's, as they did when it was
424
+ * injected client-side. No `</head>` in the segment → `late` goes first. */
425
+ function closeHead(segments: AppHtmlSegments, late: string, nonce?: string): string {
426
+ const { before, after } = headSplit(segments);
427
+ return (
428
+ (before ? interpolateSegment(before, { nonce }) : "") +
429
+ late +
430
+ interpolateSegment(after, { nonce })
431
+ );
432
+ }
433
+
434
+ /** Plugin `head` fragments, one per line, empty ones skipped. */
435
+ function headExtraTags(headExtras?: string[]): string {
436
+ let out = "";
437
+ for (const fragment of headExtras ?? []) {
438
+ if (fragment) out += ` ${fragment}\n`;
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /** `<title>{text}</title>` unless app.html or one of the head parts already
444
+ * has a title. Shared by buildHtml and buildMetadataChunk so both paths make
445
+ * the same call; each passes every part it writes into <head> (metadata,
446
+ * plugin extras, <svelte:head>). */
447
+ function titleFallback(
448
+ segments: AppHtmlSegments | undefined,
449
+ metadata: Metadata | null | undefined,
450
+ text: string,
451
+ ...parts: string[]
452
+ ): string {
453
+ if (metadata?.title || (segments && appHtmlHasTitle(segments))) return "";
454
+ return parts.some(hasTitle) ? "" : `<title>${escapeHtml(text)}</title>`;
455
+ }
456
+
457
+ /** Whether app.html sets its own <title> in its head. */
458
+ export function appHtmlHasTitle(segments: AppHtmlSegments): boolean {
459
+ return headSplit(segments).hasTitle;
460
+ }
461
+
462
+ /** <svelte:head>, then the fallback title, for the very end of <head>. The
463
+ * fallback goes last so that if hasTitle ever misses a real <title>, the real
464
+ * one still comes first and wins; the cost is a spare tag, not a wrong title. */
465
+ function lateHead(head: string, fallbackTitle: string): string {
466
+ return (head ? ` ${head}\n` : "") + (fallbackTitle ? ` ${fallbackTitle}\n` : "");
467
+ }
468
+
469
+ export type MetadataChunkOptions = {
470
+ /** Plugin `head` fragments; they go right after the metadata() tags. */
471
+ headExtras?: string[];
472
+ segments?: AppHtmlSegments;
473
+ /** <svelte:head> output; it goes last, just before app.html's </head>. */
474
+ head?: string;
475
+ nonce?: string;
476
+ };
477
+
427
478
  /** Chunk 2: metadata tags + close </head> + open <body> + spinner */
428
479
  export function buildMetadataChunk(
429
480
  metadata: Metadata | null,
430
- headExtras?: string[],
431
- segments?: AppHtmlSegments,
481
+ { headExtras, segments, head = "", nonce }: MetadataChunkOptions = {},
432
482
  ): string {
433
- let out = "\n";
434
- out += metadata ? metadataTags(metadata) : ` <title>Bosia App</title>\n`;
435
- if (headExtras?.length) {
436
- for (const fragment of headExtras) {
437
- if (fragment) out += ` ${fragment}\n`;
438
- }
439
- }
483
+ // `head` goes in the real <head>, not in a script, so crawlers that don't run
484
+ // JS still see JSON-LD, icons and og tags. Same order as buildHtml: metadata,
485
+ // plugin extras, app.html's own head markup, then <svelte:head>.
486
+ const metaTags = metadataTags(metadata);
487
+ const extras = headExtraTags(headExtras);
488
+ const fallbackTitle = titleFallback(segments, metadata, DEFAULT_TITLE, extras, head);
489
+ let out = "\n" + metaTags + extras;
490
+ const late = lateHead(head, fallbackTitle);
440
491
 
441
492
  if (segments) {
442
- const headCloseInterpolated = interpolateSegment(segments.headClose, {});
443
- out += headCloseInterpolated + `\n${SPINNER}`;
493
+ out += closeHead(segments, late, nonce) + `\n${SPINNER}`;
444
494
  } else {
445
- out += `</head>\n<body>\n${SPINNER}`;
495
+ out += `${late}</head>\n<body>\n${SPINNER}`;
446
496
  }
447
497
 
448
- // All markup, no data islands — safe to rebase wholesale, which is what picks
449
- // up an app's own headExtras (a canonical link, an og:image on a local file).
498
+ // No loader data islands here, so it's safe to rebase wholesale. That picks up
499
+ // an app's own headExtras (a canonical link, an og:image on a local file) and
500
+ // <svelte:head> output, which buildHtml rebases the same way.
450
501
  return rebaseHtmlAttrs(B, out);
451
502
  }
452
503
 
@@ -462,9 +513,24 @@ export function escapeAttr(s: string): string {
462
513
  .replace(/>/g, "&gt;");
463
514
  }
464
515
 
516
+ /** The window flags hydration reads, shared by buildHtml and buildHtmlTail.
517
+ * `__BOSIA_TITLE__` is the title metadata() gave: navigation may replace it,
518
+ * but not a title a component set after it (client/App.svelte). */
519
+ function sysScriptBody(
520
+ ssr: boolean,
521
+ title: string | undefined,
522
+ pageDeps: any,
523
+ layoutDeps: any[] | null,
524
+ ): string {
525
+ let js = ssr ? "" : "window.__BOSIA_SSR__=false;";
526
+ if (title) js += `window.__BOSIA_TITLE__=${safeJsonStringify(title)};`;
527
+ if (pageDeps !== null || layoutDeps !== null)
528
+ js += `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`;
529
+ return js;
530
+ }
531
+
465
532
  export function buildHtmlTail(
466
533
  body: string,
467
- head: string,
468
534
  pageData: any,
469
535
  layoutData: any[],
470
536
  csr: boolean,
@@ -475,16 +541,14 @@ export function buildHtmlTail(
475
541
  pageDeps: any = null,
476
542
  layoutDeps: any[] | null = null,
477
543
  segments?: AppHtmlSegments,
544
+ ssrTitle?: string,
478
545
  ): string {
479
546
  // Same rebase as buildHtml — the streamed tail carries the identical markup.
480
547
  body = rebaseHtmlAttrs(B, body);
481
- head = rebaseHtmlAttrs(B, head);
482
548
 
483
549
  const n = nonceAttr(nonce);
484
550
  let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
485
551
  out += `\n<div id="app">${body}</div>`;
486
- if (head)
487
- out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
488
552
  if (csr) {
489
553
  out += baseScript(nonce);
490
554
  const publicEnv = getPublicDynamicEnv();
@@ -496,14 +560,8 @@ export function buildHtmlTail(
496
560
  if (formData != null) {
497
561
  out += `\n<script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`;
498
562
  }
499
- const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
500
- const depsInject =
501
- pageDeps !== null || layoutDeps !== null
502
- ? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
503
- : "";
504
- if (ssrFlag || depsInject) {
505
- out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
506
- }
563
+ const sys = sysScriptBody(ssr, ssrTitle, pageDeps, layoutDeps);
564
+ if (sys) out += `\n<script${n}>${sys}</script>`;
507
565
  out += `\n<script${n} type="module" src="${ENTRY}"></script>`;
508
566
  } else if (isDev) {
509
567
  out += `\n<script${n}>!function r(){var e=new EventSource("${SSE}");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;