bosia 0.9.1 → 0.9.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.
- package/package.json +1 -1
- package/src/core/html.ts +39 -28
- package/src/core/renderer.ts +68 -10
package/package.json
CHANGED
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
|
-
|
|
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,34 @@ 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
|
+
/** The `metadata()` tags themselves, indented head-ready. Shared by the streaming
|
|
291
|
+
* path (buildMetadataChunk) and the non-streaming one (buildHtml) so the two
|
|
292
|
+
* renderers cannot drift on what `metadata()` emits. */
|
|
293
|
+
export function metadataTags(metadata: Metadata | null): string {
|
|
294
|
+
if (!metadata) return "";
|
|
295
|
+
let out = "";
|
|
296
|
+
if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
|
|
297
|
+
if (metadata.description) {
|
|
298
|
+
out += ` <meta name="description" content="${escapeAttr(metadata.description)}">\n`;
|
|
299
|
+
}
|
|
300
|
+
if (metadata.meta) {
|
|
301
|
+
for (const m of metadata.meta) {
|
|
302
|
+
const attrs = m.name
|
|
303
|
+
? `name="${escapeAttr(m.name)}"`
|
|
304
|
+
: `property="${escapeAttr(m.property ?? "")}"`;
|
|
305
|
+
out += ` <meta ${attrs} content="${escapeAttr(m.content)}">\n`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (metadata.link) {
|
|
309
|
+
for (const l of metadata.link) {
|
|
310
|
+
let attrs = `rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"`;
|
|
311
|
+
if (l.hreflang) attrs += ` hreflang="${escapeAttr(l.hreflang)}"`;
|
|
312
|
+
out += ` <link ${attrs}>\n`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
285
318
|
/** Chunk 2: metadata tags + close </head> + open <body> + spinner */
|
|
286
319
|
export function buildMetadataChunk(
|
|
287
320
|
metadata: Metadata | null,
|
|
@@ -289,29 +322,7 @@ export function buildMetadataChunk(
|
|
|
289
322
|
segments?: AppHtmlSegments,
|
|
290
323
|
): string {
|
|
291
324
|
let out = "\n";
|
|
292
|
-
|
|
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
|
-
}
|
|
325
|
+
out += metadata ? metadataTags(metadata) : ` <title>Bosia App</title>\n`;
|
|
315
326
|
if (headExtras?.length) {
|
|
316
327
|
for (const fragment of headExtras) {
|
|
317
328
|
if (fragment) out += ` ${fragment}\n`;
|
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
-
|
|
960
|
+
metadata?.lang,
|
|
914
961
|
false,
|
|
915
962
|
nonce,
|
|
916
963
|
data.pageDeps,
|
|
917
964
|
data.layoutDeps,
|
|
918
|
-
|
|
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
|
-
|
|
990
|
+
metadata?.lang,
|
|
943
991
|
true,
|
|
944
992
|
nonce,
|
|
945
993
|
data.pageDeps,
|
|
946
994
|
data.layoutDeps,
|
|
947
|
-
|
|
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:
|
|
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) {
|