bosia 0.9.1 → 0.9.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -394,18 +394,29 @@
394
394
 
395
395
  settleScroll();
396
396
 
397
- // Update document title and meta description from server metadata
398
- if (result?.metadata) {
399
- if (result.metadata.title) document.title = result.metadata.title;
400
- if (result.metadata.description) {
401
- let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
402
- if (!meta) {
403
- meta = document.createElement("meta");
404
- meta.name = "description";
405
- document.head.appendChild(meta);
406
- }
407
- meta.content = result.metadata.description;
408
- }
397
+ // Re-emit the head from server metadata. `data-bosia-meta` (html.ts `OWNED`)
398
+ // marks exactly the tags metadata() owns, so headExtras, the framework's own
399
+ // static tags and <svelte:head> output are never touched. `result === null`
400
+ // with server data means the fetch failed — leave the head as it is; without
401
+ // server data there is no metadata() at all, so clearing matches SSR.
402
+ if (result || !match.route.hasServerData) {
403
+ const md = result?.metadata;
404
+ document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
405
+ // A page that declares no title keeps the previous one rather than flashing
406
+ // the "Bosia App" fallback. Give metadata() a title if that matters.
407
+ if (md?.title) document.title = md.title;
408
+ if (md?.lang) document.documentElement.lang = md.lang;
409
+ const emit = (tag: "meta" | "link", attrs: Record<string, string | undefined>) => {
410
+ const el = document.createElement(tag);
411
+ for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, v);
412
+ el.setAttribute("data-bosia-meta", "");
413
+ document.head.appendChild(el);
414
+ };
415
+ if (md?.description) emit("meta", { name: "description", content: md.description });
416
+ for (const m of md?.meta ?? [])
417
+ emit("meta", { name: m.name, property: m.property, content: m.content });
418
+ for (const l of md?.link ?? [])
419
+ emit("link", { rel: l.rel, href: l.href, hreflang: l.hreflang });
409
420
  }
410
421
 
411
422
  settle({ url, params: match.params });
package/src/core/html.ts CHANGED
@@ -6,6 +6,7 @@ import { rebaseHtmlAttrs } from "./basePath.ts";
6
6
  import { currentBase } from "./appBase.ts";
7
7
  import type { AppHtmlSegments } from "./appHtml.ts";
8
8
  import { interpolateSegment } from "./appHtml.ts";
9
+ import type { Metadata } from "./hooks.ts";
9
10
 
10
11
  // ─── Dist Manifest ───────────────────────────────────────
11
12
  // Maps hashed filenames → script/link tags.
@@ -143,6 +144,7 @@ export function buildHtml(
143
144
  layoutDeps: any[] | null = null,
144
145
  bodyEndExtras?: string[],
145
146
  segments?: AppHtmlSegments,
147
+ metadata?: Metadata | null,
146
148
  ): string {
147
149
  // An app writes <a href="/masuk">; under a base the browser has to be handed
148
150
  // /sso/masuk or it walks off this app entirely. Only the rendered markup is
@@ -155,7 +157,12 @@ export function buildHtml(
155
157
  .map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
156
158
  .join("\n ");
157
159
 
158
- const fallbackTitle = head.includes("<title>") ? "" : "<title>Bosia App</title>";
160
+ // Metadata goes in before `head`: the first <title> in the document wins, and
161
+ // the streaming path already puts metadata() ahead of <svelte:head> content
162
+ // (which arrives later via buildHtmlTail). Same order = same winner on both paths.
163
+ const metaTags = rebaseHtmlAttrs(B, metadataTags(metadata ?? null));
164
+ const fallbackTitle =
165
+ metaTags.includes("<title>") || head.includes("<title>") ? "" : "<title>Bosia App</title>";
159
166
 
160
167
  const n = nonceAttr(nonce);
161
168
  const publicEnv = getPublicDynamicEnv();
@@ -206,7 +213,7 @@ export function buildHtml(
206
213
  `\n ${faviconLine}${cssLinks}\n` +
207
214
  ` ${twCssLink()}\n` +
208
215
  ` <script${n}>${THEME_INIT_JS}</script>\n` +
209
- ` ${fallbackTitle}${head}` +
216
+ ` ${fallbackTitle}${metaTags}${head}` +
210
217
  headCloseInterpolated +
211
218
  (body ? "" : `\n${SPINNER}`) +
212
219
  `\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
@@ -221,7 +228,7 @@ export function buildHtml(
221
228
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
222
229
  ${fallbackTitle}
223
230
  <link rel="icon" type="image/svg+xml" href="${FAVICON}">
224
- ${head}
231
+ ${metaTags} ${head}
225
232
  ${cssLinks}
226
233
  ${twCssLink()}
227
234
  <script${n}>${THEME_INIT_JS}</script>
@@ -234,8 +241,6 @@ export function buildHtml(
234
241
 
235
242
  // ─── Streaming HTML Helpers ──────────────────────────────
236
243
 
237
- import type { Metadata } from "./hooks.ts";
238
-
239
244
  /** Chunk 1: everything from <!DOCTYPE> through CSS/modulepreload links (head still open) */
240
245
  export function buildHtmlShellOpen(
241
246
  lang?: string,
@@ -282,6 +287,39 @@ const SPINNER =
282
287
  `border-radius:50%;animation:__bs__ .8s linear infinite}` +
283
288
  `@keyframes __bs__{to{transform:rotate(360deg)}}</style><i></i></div>`;
284
289
 
290
+ /** Marks the tags `metadata()` owns, so the client router can replace exactly
291
+ * these on navigation and leave `headExtras`, the framework's own static tags
292
+ * and `<svelte:head>` output alone. Read by `client/App.svelte`. */
293
+ export const OWNED = "data-bosia-meta";
294
+
295
+ /** The `metadata()` tags themselves, indented head-ready. Shared by the streaming
296
+ * path (buildMetadataChunk) and the non-streaming one (buildHtml) so the two
297
+ * renderers cannot drift on what `metadata()` emits. */
298
+ export function metadataTags(metadata: Metadata | null): string {
299
+ if (!metadata) return "";
300
+ let out = "";
301
+ if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
302
+ if (metadata.description) {
303
+ out += ` <meta name="description" content="${escapeAttr(metadata.description)}" ${OWNED}>\n`;
304
+ }
305
+ if (metadata.meta) {
306
+ for (const m of metadata.meta) {
307
+ const attrs = m.name
308
+ ? `name="${escapeAttr(m.name)}"`
309
+ : `property="${escapeAttr(m.property ?? "")}"`;
310
+ out += ` <meta ${attrs} content="${escapeAttr(m.content)}" ${OWNED}>\n`;
311
+ }
312
+ }
313
+ if (metadata.link) {
314
+ for (const l of metadata.link) {
315
+ let attrs = `rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"`;
316
+ if (l.hreflang) attrs += ` hreflang="${escapeAttr(l.hreflang)}"`;
317
+ out += ` <link ${attrs} ${OWNED}>\n`;
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+
285
323
  /** Chunk 2: metadata tags + close </head> + open <body> + spinner */
286
324
  export function buildMetadataChunk(
287
325
  metadata: Metadata | null,
@@ -289,29 +327,7 @@ export function buildMetadataChunk(
289
327
  segments?: AppHtmlSegments,
290
328
  ): string {
291
329
  let out = "\n";
292
- if (metadata) {
293
- if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
294
- if (metadata.description) {
295
- out += ` <meta name="description" content="${escapeAttr(metadata.description)}">\n`;
296
- }
297
- if (metadata.meta) {
298
- for (const m of metadata.meta) {
299
- const attrs = m.name
300
- ? `name="${escapeAttr(m.name)}"`
301
- : `property="${escapeAttr(m.property ?? "")}"`;
302
- out += ` <meta ${attrs} content="${escapeAttr(m.content)}">\n`;
303
- }
304
- }
305
- if (metadata.link) {
306
- for (const l of metadata.link) {
307
- let attrs = `rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"`;
308
- if (l.hreflang) attrs += ` hreflang="${escapeAttr(l.hreflang)}"`;
309
- out += ` <link ${attrs}>\n`;
310
- }
311
- }
312
- } else {
313
- out += ` <title>Bosia App</title>\n`;
314
- }
330
+ out += metadata ? metadataTags(metadata) : ` <title>Bosia App</title>\n`;
315
331
  if (headExtras?.length) {
316
332
  for (const fragment of headExtras) {
317
333
  if (fragment) out += ` ${fragment}\n`;
@@ -509,6 +509,9 @@ export async function loadMetadata(
509
509
  );
510
510
  }
511
511
  } catch (err) {
512
+ // Control flow thrown from metadata() is intent, not failure — swallowing it
513
+ // here made every caller's Redirect/HttpError branch dead code.
514
+ if (err instanceof Redirect || err instanceof HttpError) throw err;
512
515
  if (isDev) console.error("Metadata load error:", err);
513
516
  else console.error("Metadata load error:", (err as Error).message ?? err);
514
517
  if (isDev) reportDevErrorFromCatch(err);
@@ -845,7 +848,9 @@ export async function renderSSRStream(
845
848
 
846
849
  // ─── Form Action Page Renderer ───────────────────────────
847
850
  // Re-runs load functions after a form action, renders with form data.
848
- // Uses non-streaming buildHtml so we can control the status code.
851
+ // Uses non-streaming buildHtml so we can control the status code. Resolves
852
+ // metadata() here too — the GET path's title, meta/link tags, lang and
853
+ // metadata.data must survive a submit, or the page changes under the user.
849
854
 
850
855
  export async function renderPageWithFormData(
851
856
  url: URL,
@@ -871,11 +876,36 @@ export async function renderPageWithFormData(
871
876
  nonce,
872
877
  );
873
878
 
874
- const { route } = match;
879
+ const { route, params } = match;
880
+
881
+ // Serial, not parallel: metadata.data feeds the loader below.
882
+ let metadata: Metadata | null = null;
883
+ try {
884
+ metadata = await loadMetadata(route, params, url, locals, cookies, req);
885
+ } catch (err) {
886
+ if (err instanceof Redirect) return Response.redirect(err.location, err.status);
887
+ if (err instanceof HttpError) {
888
+ return renderErrorPage(
889
+ err.status,
890
+ err.message,
891
+ url,
892
+ req,
893
+ route,
894
+ undefined,
895
+ undefined,
896
+ undefined,
897
+ nonce,
898
+ );
899
+ }
900
+ if (isDev) console.error("Metadata load error:", err);
901
+ else console.error("Metadata load error:", (err as Error).message ?? err);
902
+ if (isDev) reportDevErrorFromCatch(err);
903
+ // Continue with null metadata — don't break the page for a metadata failure
904
+ }
875
905
 
876
906
  // Load components + data in parallel
877
907
  const [data, pageMod, layoutMods] = await Promise.all([
878
- loadRouteData(url, locals, req, cookies, null, match),
908
+ loadRouteData(url, locals, req, cookies, metadata?.data ?? null, match),
879
909
  route.pageModule(),
880
910
  Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
881
911
  ]);
@@ -893,6 +923,23 @@ export async function renderPageWithFormData(
893
923
  nonce,
894
924
  );
895
925
 
926
+ const renderCtx: RenderContext = {
927
+ request: req,
928
+ url,
929
+ route: { pattern: route.pattern },
930
+ metadata,
931
+ };
932
+ const [headExtras, bodyEndExtras] = await Promise.all([
933
+ pluginRenderFragments("head", renderCtx),
934
+ pluginRenderFragments("bodyEnd", renderCtx),
935
+ ]);
936
+ // buildHtml has no headExtras slot; fold them in ahead of the SSR head, which
937
+ // is where buildMetadataChunk puts them on the streaming path.
938
+ const headWithExtras = (ssrHead: string) => {
939
+ const extras = headExtras.filter(Boolean);
940
+ return extras.length ? `${extras.join("\n ")}\n ${ssrHead}` : ssrHead;
941
+ };
942
+
896
943
  // Form-action re-render always runs every loader (no client mask).
897
944
  const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
898
945
  const pageDataFull = data.pageData ?? {};
@@ -905,18 +952,19 @@ export async function renderPageWithFormData(
905
952
  }
906
953
  const html = buildHtml(
907
954
  "",
908
- "",
955
+ headWithExtras(""),
909
956
  pageDataFull,
910
957
  layoutDataFull,
911
958
  true,
912
959
  formData,
913
- undefined,
960
+ metadata?.lang,
914
961
  false,
915
962
  nonce,
916
963
  data.pageDeps,
917
964
  data.layoutDeps,
918
- undefined,
965
+ bodyEndExtras,
919
966
  appHtmlSegments,
967
+ metadata,
920
968
  );
921
969
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
922
970
  }
@@ -934,18 +982,19 @@ export async function renderPageWithFormData(
934
982
 
935
983
  const html = buildHtml(
936
984
  body,
937
- head,
985
+ headWithExtras(head),
938
986
  pageDataFull,
939
987
  layoutDataFull,
940
988
  data.csr,
941
989
  formData,
942
- undefined,
990
+ metadata?.lang,
943
991
  true,
944
992
  nonce,
945
993
  data.pageDeps,
946
994
  data.layoutDeps,
947
- undefined,
995
+ bodyEndExtras,
948
996
  appHtmlSegments,
997
+ metadata,
949
998
  );
950
999
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
951
1000
  }
@@ -971,6 +1020,13 @@ export async function renderErrorPage(
971
1020
  // is dead bytes without a matching policy header.
972
1021
  if (!CSP_ENABLED) nonce = undefined;
973
1022
 
1023
+ // The route's own metadata() is deliberately NOT run here — the route may be
1024
+ // what failed, and a 404 has no route at all. Synthesize a status title only
1025
+ // when the error component didn't set one, so <svelte:head><title> in a custom
1026
+ // +error.svelte still wins.
1027
+ const errMeta = (head: string): Metadata | null =>
1028
+ head.includes("<title>") ? null : { title: `${status} — ${message}` };
1029
+
974
1030
  // Inspector overlay and other plugin bodyEnd fragments must be injected
975
1031
  // on error pages too — otherwise SSE never connects and runtime errors
976
1032
  // from the failing render are invisible in the UI.
@@ -978,7 +1034,7 @@ export async function renderErrorPage(
978
1034
  request: req,
979
1035
  url,
980
1036
  route: route ? { pattern: route.pattern } : { pattern: "" },
981
- metadata: null,
1037
+ metadata: errMeta(""),
982
1038
  };
983
1039
  const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
984
1040
 
@@ -1024,6 +1080,7 @@ export async function renderErrorPage(
1024
1080
  null,
1025
1081
  bodyEndExtras,
1026
1082
  appHtmlSegments,
1083
+ errMeta(head),
1027
1084
  );
1028
1085
  return compress(html, "text/html; charset=utf-8", req, status);
1029
1086
  } catch (err) {
@@ -1059,6 +1116,7 @@ export async function renderErrorPage(
1059
1116
  null,
1060
1117
  bodyEndExtras,
1061
1118
  appHtmlSegments,
1119
+ errMeta(head),
1062
1120
  );
1063
1121
  return compress(html, "text/html; charset=utf-8", req, status);
1064
1122
  } catch (err) {
@@ -303,7 +303,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
303
303
  cookies,
304
304
  request,
305
305
  );
306
- if (meta) metadata = { title: meta.title, description: meta.description };
306
+ // Explicit whitelist, not a spread: `metadata.data` feeds load() on the
307
+ // server and may hold secrets — it must not reach the client.
308
+ if (meta)
309
+ metadata = {
310
+ title: meta.title,
311
+ description: meta.description,
312
+ meta: meta.meta,
313
+ link: meta.link,
314
+ lang: meta.lang,
315
+ };
307
316
  } catch {
308
317
  /* non-fatal */
309
318
  }