bosia 1.1.5 → 1.2.0
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/appHtml.ts +17 -0
- package/src/core/cache.ts +3 -0
- package/src/core/client/router.svelte.ts +1 -2
- package/src/core/html.ts +208 -29
- package/src/core/renderer.ts +145 -93
- package/src/core/server.ts +113 -89
- package/src/lib/utils.ts +1 -8
- package/templates/default/package.json +2 -2
- package/templates/demo/package.json +2 -2
- package/templates/shop/package.json +2 -2
- package/templates/shop/src/routes/(private)/dashboard/files/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/products/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/users/+page.svelte +9 -0
- package/templates/store/package.json +2 -2
- package/templates/store/src/routes/(private)/dashboard/files/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/products/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/users/+page.svelte +9 -0
package/package.json
CHANGED
package/src/core/appHtml.ts
CHANGED
|
@@ -62,6 +62,23 @@ export function loadAppHtmlTemplate(cwd: string = process.cwd()): AppHtmlSegment
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Comments and <script>/<style> bodies are matched only to be skipped; the
|
|
66
|
+
// first real `</head …>` or, when the optional </head> is left out, `<body`
|
|
67
|
+
// is where the head ends.
|
|
68
|
+
const HEAD_END_RE =
|
|
69
|
+
/<!--[\s\S]*?(?:-->|$)|<(script|style)\b[\s\S]*?(?:<\/\1\s*>|$)|<\/head\s*>|<body[\s>/]/gi;
|
|
70
|
+
|
|
71
|
+
/** Index in headClose (which runs on to %bosia.body%) where the head ends, or -1.
|
|
72
|
+
* Matches case-insensitively on the original string: lowercasing a copy can
|
|
73
|
+
* change its length (`İ`) and shift the index. */
|
|
74
|
+
export function findHeadEnd(headClose: string): number {
|
|
75
|
+
for (const m of headClose.matchAll(HEAD_END_RE)) {
|
|
76
|
+
if (m[0].startsWith("<!--") || m[1]) continue;
|
|
77
|
+
return m.index;
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
function replaceStaticPlaceholders(template: string): string {
|
|
66
83
|
// Replace %bosia.assets% with BOSIA_ASSETS_BASE env var
|
|
67
84
|
const assetsBase = process.env.BOSIA_ASSETS_BASE || "";
|
package/src/core/cache.ts
CHANGED
|
@@ -404,6 +404,9 @@ export function serveCached(entry: CacheEntry, req: Request): Response {
|
|
|
404
404
|
return new Response(null, { status: 304, headers });
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
|
+
// BosiaApp drops HEAD bodies, and a null body would report Content-Length 0.
|
|
408
|
+
// GET leaves it to the runtime, so a hook that rewrites the body stays valid.
|
|
409
|
+
if (req.method === "HEAD") headers["content-length"] = String(body.byteLength);
|
|
407
410
|
if (encoding) {
|
|
408
411
|
headers["content-encoding"] = encoding;
|
|
409
412
|
return new Response(body, { ...PRECOMPRESSED, status: entry.status, headers });
|
|
@@ -90,8 +90,7 @@ export const router = new (class Router {
|
|
|
90
90
|
pendingScroll: { x: number; y: number } | null = null;
|
|
91
91
|
/** Set by App.svelte; returns the mounted page's `export const snapshot` (if any) at capture time. */
|
|
92
92
|
getPageSnapshot:
|
|
93
|
-
|
|
94
|
-
| null = null;
|
|
93
|
+
(() => { capture: () => unknown; restore: (v: any) => void } | undefined) | null = null;
|
|
95
94
|
/** Captured snapshot for the entry a popstate/reload landed on; consumed once by App.svelte. `undefined` = nothing to restore. */
|
|
96
95
|
pendingSnapshot: unknown = undefined;
|
|
97
96
|
|
package/src/core/html.ts
CHANGED
|
@@ -5,7 +5,7 @@ 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 { findHeadEnd, interpolateSegment } from "./appHtml.ts";
|
|
9
9
|
import type { Metadata } from "./hooks.ts";
|
|
10
10
|
|
|
11
11
|
// Workers compresses any body sent with a Content-Encoding header — again, if it
|
|
@@ -249,6 +249,8 @@ export function buildHtml(
|
|
|
249
249
|
segments?: AppHtmlSegments,
|
|
250
250
|
metadata?: Metadata | null,
|
|
251
251
|
pattern?: string,
|
|
252
|
+
headExtras?: string[],
|
|
253
|
+
fallbackTitle = DEFAULT_TITLE,
|
|
252
254
|
): string {
|
|
253
255
|
// An app writes <a href="/masuk">; under a base the browser has to be handed
|
|
254
256
|
// /sso/masuk or it walks off this app entirely. Only the rendered markup is
|
|
@@ -258,11 +260,11 @@ export function buildHtml(
|
|
|
258
260
|
head = rebaseHtmlAttrs(B, head);
|
|
259
261
|
|
|
260
262
|
// Metadata goes in before `head`: the first <title> in the document wins, and
|
|
261
|
-
// the
|
|
262
|
-
//
|
|
263
|
+
// buildMetadataChunk uses the same order on the streaming path, so both paths
|
|
264
|
+
// pick the same winner.
|
|
263
265
|
const metaTags = rebaseHtmlAttrs(B, metadataTags(metadata ?? null));
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
+
const extras = rebaseHtmlAttrs(B, headExtraTags(headExtras));
|
|
267
|
+
const fallback = titleFallback(segments, metadata, fallbackTitle, extras, head);
|
|
266
268
|
|
|
267
269
|
const n = nonceAttr(nonce);
|
|
268
270
|
const publicEnv = getPublicDynamicEnv();
|
|
@@ -307,7 +309,6 @@ export function buildHtml(
|
|
|
307
309
|
lang: safeKey,
|
|
308
310
|
nonce,
|
|
309
311
|
});
|
|
310
|
-
const headCloseInterpolated = interpolateSegment(segments.headClose, { nonce });
|
|
311
312
|
const tailInterpolated = interpolateSegment(segments.tail, { nonce });
|
|
312
313
|
const faviconLine = segments.hasCustomFavicon
|
|
313
314
|
? ""
|
|
@@ -319,8 +320,8 @@ export function buildHtml(
|
|
|
319
320
|
componentCssLinks() +
|
|
320
321
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
321
322
|
preloads +
|
|
322
|
-
` ${
|
|
323
|
-
|
|
323
|
+
` ${metaTags}${extras}` +
|
|
324
|
+
closeHead(segments, lateHead(head, fallback), nonce) +
|
|
324
325
|
(body ? "" : `\n${SPINNER}`) +
|
|
325
326
|
`\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
|
|
326
327
|
tailInterpolated
|
|
@@ -332,10 +333,8 @@ export function buildHtml(
|
|
|
332
333
|
<head>
|
|
333
334
|
<meta charset="UTF-8">
|
|
334
335
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
335
|
-
${fallbackTitle}
|
|
336
336
|
<link rel="icon" type="image/svg+xml" href="${FAVICON}">
|
|
337
|
-
${metaTags}
|
|
338
|
-
${twCssLink()}
|
|
337
|
+
${metaTags}${extras}${lateHead(head, fallback)} ${twCssLink()}
|
|
339
338
|
${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
|
|
340
339
|
${preloads}</head>
|
|
341
340
|
<body>
|
|
@@ -424,29 +423,213 @@ export function metadataTags(metadata: Metadata | null): string {
|
|
|
424
423
|
return out;
|
|
425
424
|
}
|
|
426
425
|
|
|
426
|
+
/** app.html's headClose with `late` inserted just before its `</head>`, after any
|
|
427
|
+
* markup the app put after %bosia.head%, so <svelte:head> stays last in <head>
|
|
428
|
+
* and a page's styles and meta win over app.html's, as they did when it was
|
|
429
|
+
* injected client-side. No `</head>` in the segment → `late` goes first. */
|
|
430
|
+
function closeHead(segments: AppHtmlSegments, late: string, nonce?: string): string {
|
|
431
|
+
const { before, after } = headSplit(segments);
|
|
432
|
+
return (
|
|
433
|
+
(before ? interpolateSegment(before, { nonce }) : "") +
|
|
434
|
+
late +
|
|
435
|
+
interpolateSegment(after, { nonce })
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
type HeadSplit = { before: string; after: string; hasTitle: boolean };
|
|
440
|
+
|
|
441
|
+
// app.html never changes after it is parsed, so split it and check it for a
|
|
442
|
+
// title once per segments object instead of on every render. Kept in memory,
|
|
443
|
+
// not in dist/app-html.json, so it can't go stale against headClose.
|
|
444
|
+
const headSplits = new WeakMap<AppHtmlSegments, HeadSplit>();
|
|
445
|
+
|
|
446
|
+
function headSplit(segments: AppHtmlSegments): HeadSplit {
|
|
447
|
+
let split = headSplits.get(segments);
|
|
448
|
+
if (!split) {
|
|
449
|
+
const { headOpen, headClose } = segments;
|
|
450
|
+
const i = findHeadEnd(headClose);
|
|
451
|
+
const before = i < 0 ? "" : headClose.slice(0, i);
|
|
452
|
+
const after = i < 0 ? headClose : headClose.slice(i);
|
|
453
|
+
split = { before, after, hasTitle: hasTitle(headOpen) || hasTitle(before) };
|
|
454
|
+
headSplits.set(segments, split);
|
|
455
|
+
}
|
|
456
|
+
return split;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Whether a chunk of head markup already sets the document title. Matches a
|
|
460
|
+
* <title> with attributes too. Ignores one in a comment, an attribute value, a
|
|
461
|
+
* script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
|
|
462
|
+
* (which only labels the icon). */
|
|
463
|
+
export function hasTitle(html: string): boolean {
|
|
464
|
+
// A single forward scan, not a regex: a lazy `<script…</script>` match retries
|
|
465
|
+
// from every unclosed `<script` and goes quadratic on hostile {@html} output.
|
|
466
|
+
// Each tag is skipped whole, quotes included, so nothing inside an attribute
|
|
467
|
+
// looks like a tag. This is a heuristic, not a full HTML tokenizer.
|
|
468
|
+
let unclosed: Set<string> | undefined;
|
|
469
|
+
let i = 0;
|
|
470
|
+
while ((i = html.indexOf("<", i)) !== -1) {
|
|
471
|
+
if (html.startsWith("<!--", i)) {
|
|
472
|
+
i = commentEnd(html, i);
|
|
473
|
+
if (i === -1) return false; // unclosed comment runs to the end
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!isAsciiAlpha(html.charCodeAt(i + 1))) {
|
|
477
|
+
i++;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const nameEnd = tagNameEnd(html, i + 1);
|
|
481
|
+
const tagEnd = openTagEnd(html, nameEnd);
|
|
482
|
+
if (tagEnd === -1) return false; // the rest is inside an unfinished tag
|
|
483
|
+
const name = nameEnd - i - 1 <= MAX_NAME ? html.slice(i + 1, nameEnd).toLowerCase() : "";
|
|
484
|
+
if (name === "title") return true;
|
|
485
|
+
i = tagEnd;
|
|
486
|
+
const close = SKIPPED_TAGS.get(name);
|
|
487
|
+
// Only foreign content like <svg/> honors the self-closing slash.
|
|
488
|
+
const selfClosed = name === "svg" && html.charCodeAt(tagEnd - 2) === 47;
|
|
489
|
+
if (!close || selfClosed || unclosed?.has(name)) continue;
|
|
490
|
+
// An opener with no closer is read as a plain tag, and remembered, so
|
|
491
|
+
// later openers of that tag don't search to the end again.
|
|
492
|
+
close.lastIndex = tagEnd;
|
|
493
|
+
const gt = close.exec(html) ? html.indexOf(">", close.lastIndex) : -1;
|
|
494
|
+
if (gt === -1) (unclosed ??= new Set()).add(name);
|
|
495
|
+
else i = gt + 1;
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** `</tag` followed by whitespace, `/` or `>`; the caller finds the `>` itself,
|
|
501
|
+
* since a `[^>]*>` here would rescan to the end for every candidate. */
|
|
502
|
+
const SKIPPED_TAGS = new Map(
|
|
503
|
+
["script", "style", "noscript", "template", "svg"].map((t) => [
|
|
504
|
+
t,
|
|
505
|
+
new RegExp(`</${t}(?=[\\t\\n\\f\\r />])`, "gi"),
|
|
506
|
+
]),
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
/** Longest tag name hasTitle needs to tell apart; longer names are never sliced. */
|
|
510
|
+
const MAX_NAME = Math.max("title".length, ...[...SKIPPED_TAGS.keys()].map((t) => t.length));
|
|
511
|
+
|
|
512
|
+
/** Index just past the comment opening at `start`, or -1 when it never closes.
|
|
513
|
+
* `<!-->` and `<!--->` are complete empty comments. */
|
|
514
|
+
function commentEnd(html: string, start: number): number {
|
|
515
|
+
if (html.startsWith(">", start + 4)) return start + 5;
|
|
516
|
+
if (html.startsWith("->", start + 4)) return start + 6;
|
|
517
|
+
const end = html.indexOf("-->", start + 4);
|
|
518
|
+
return end === -1 ? -1 : end + 3;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** End of the tag name starting at `start`: the first whitespace, `/` or `>`. */
|
|
522
|
+
function tagNameEnd(html: string, start: number): number {
|
|
523
|
+
let j = start;
|
|
524
|
+
while (j < html.length) {
|
|
525
|
+
const c = html.charCodeAt(j);
|
|
526
|
+
if (isHtmlSpace(c) || c === 47 || c === 62) break;
|
|
527
|
+
j++;
|
|
528
|
+
}
|
|
529
|
+
return j;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Index just past the `>` closing a start tag, skipping quoted attribute
|
|
533
|
+
* values, or -1 when the tag never closes. */
|
|
534
|
+
function openTagEnd(html: string, from: number): number {
|
|
535
|
+
for (let j = from; j < html.length; j++) {
|
|
536
|
+
const c = html.charCodeAt(j);
|
|
537
|
+
if (c === 62) return j + 1;
|
|
538
|
+
if (c !== 61) continue; // `=` starts a value
|
|
539
|
+
do j++;
|
|
540
|
+
while (j < html.length && isHtmlSpace(html.charCodeAt(j)));
|
|
541
|
+
const q = html.charCodeAt(j);
|
|
542
|
+
if (q === 34 || q === 39) {
|
|
543
|
+
j = html.indexOf(html[j]!, j + 1);
|
|
544
|
+
if (j === -1) return -1;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
// Unquoted: runs to whitespace or `>`, quotes and `=` included.
|
|
548
|
+
while (j < html.length && !isHtmlSpace(html.charCodeAt(j)) && html.charCodeAt(j) !== 62) j++;
|
|
549
|
+
j--;
|
|
550
|
+
}
|
|
551
|
+
return -1;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** HTML whitespace: tab, LF, FF, CR, space. Not \s, which also matches NBSP. */
|
|
555
|
+
function isHtmlSpace(c: number): boolean {
|
|
556
|
+
return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function isAsciiAlpha(c: number): boolean {
|
|
560
|
+
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Plugin `head` fragments, one per line, empty ones skipped. */
|
|
564
|
+
function headExtraTags(headExtras?: string[]): string {
|
|
565
|
+
let out = "";
|
|
566
|
+
for (const fragment of headExtras ?? []) {
|
|
567
|
+
if (fragment) out += ` ${fragment}\n`;
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** The title a page gets when nothing else sets one. */
|
|
573
|
+
const DEFAULT_TITLE = "Bosia App";
|
|
574
|
+
|
|
575
|
+
/** `<title>{text}</title>` unless app.html or one of the head parts already
|
|
576
|
+
* has a title. Shared by buildHtml and buildMetadataChunk so both paths make
|
|
577
|
+
* the same call; each passes every part it writes into <head> (metadata,
|
|
578
|
+
* plugin extras, <svelte:head>). */
|
|
579
|
+
function titleFallback(
|
|
580
|
+
segments: AppHtmlSegments | undefined,
|
|
581
|
+
metadata: Metadata | null | undefined,
|
|
582
|
+
text: string,
|
|
583
|
+
...parts: string[]
|
|
584
|
+
): string {
|
|
585
|
+
if (metadata?.title || (segments && appHtmlHasTitle(segments))) return "";
|
|
586
|
+
return parts.some(hasTitle) ? "" : `<title>${escapeHtml(text)}</title>`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Whether app.html sets its own <title> in its head. */
|
|
590
|
+
export function appHtmlHasTitle(segments: AppHtmlSegments): boolean {
|
|
591
|
+
return headSplit(segments).hasTitle;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** <svelte:head>, then the fallback title, for the very end of <head>. The
|
|
595
|
+
* fallback goes last so that if hasTitle ever misses a real <title>, the real
|
|
596
|
+
* one still comes first and wins; the cost is a spare tag, not a wrong title. */
|
|
597
|
+
function lateHead(head: string, fallbackTitle: string): string {
|
|
598
|
+
return (head ? ` ${head}\n` : "") + (fallbackTitle ? ` ${fallbackTitle}\n` : "");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export type MetadataChunkOptions = {
|
|
602
|
+
/** Plugin `head` fragments; they go right after the metadata() tags. */
|
|
603
|
+
headExtras?: string[];
|
|
604
|
+
segments?: AppHtmlSegments;
|
|
605
|
+
/** <svelte:head> output; it goes last, just before app.html's </head>. */
|
|
606
|
+
head?: string;
|
|
607
|
+
nonce?: string;
|
|
608
|
+
};
|
|
609
|
+
|
|
427
610
|
/** Chunk 2: metadata tags + close </head> + open <body> + spinner */
|
|
428
611
|
export function buildMetadataChunk(
|
|
429
612
|
metadata: Metadata | null,
|
|
430
|
-
headExtras
|
|
431
|
-
segments?: AppHtmlSegments,
|
|
613
|
+
{ headExtras, segments, head = "", nonce }: MetadataChunkOptions = {},
|
|
432
614
|
): string {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
615
|
+
// `head` goes in the real <head>, not in a script, so crawlers that don't run
|
|
616
|
+
// JS still see JSON-LD, icons and og tags. Same order as buildHtml: metadata,
|
|
617
|
+
// plugin extras, app.html's own head markup, then <svelte:head>.
|
|
618
|
+
const metaTags = metadataTags(metadata);
|
|
619
|
+
const extras = headExtraTags(headExtras);
|
|
620
|
+
const fallbackTitle = titleFallback(segments, metadata, DEFAULT_TITLE, extras, head);
|
|
621
|
+
let out = "\n" + metaTags + extras;
|
|
622
|
+
const late = lateHead(head, fallbackTitle);
|
|
440
623
|
|
|
441
624
|
if (segments) {
|
|
442
|
-
|
|
443
|
-
out += headCloseInterpolated + `\n${SPINNER}`;
|
|
625
|
+
out += closeHead(segments, late, nonce) + `\n${SPINNER}`;
|
|
444
626
|
} else {
|
|
445
|
-
out +=
|
|
627
|
+
out += `${late}</head>\n<body>\n${SPINNER}`;
|
|
446
628
|
}
|
|
447
629
|
|
|
448
|
-
//
|
|
449
|
-
//
|
|
630
|
+
// No loader data islands here, so it's safe to rebase wholesale. That picks up
|
|
631
|
+
// an app's own headExtras (a canonical link, an og:image on a local file) and
|
|
632
|
+
// <svelte:head> output, which buildHtml rebases the same way.
|
|
450
633
|
return rebaseHtmlAttrs(B, out);
|
|
451
634
|
}
|
|
452
635
|
|
|
@@ -464,7 +647,6 @@ export function escapeAttr(s: string): string {
|
|
|
464
647
|
|
|
465
648
|
export function buildHtmlTail(
|
|
466
649
|
body: string,
|
|
467
|
-
head: string,
|
|
468
650
|
pageData: any,
|
|
469
651
|
layoutData: any[],
|
|
470
652
|
csr: boolean,
|
|
@@ -478,13 +660,10 @@ export function buildHtmlTail(
|
|
|
478
660
|
): string {
|
|
479
661
|
// Same rebase as buildHtml — the streamed tail carries the identical markup.
|
|
480
662
|
body = rebaseHtmlAttrs(B, body);
|
|
481
|
-
head = rebaseHtmlAttrs(B, head);
|
|
482
663
|
|
|
483
664
|
const n = nonceAttr(nonce);
|
|
484
665
|
let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
|
|
485
666
|
out += `\n<div id="app">${body}</div>`;
|
|
486
|
-
if (head)
|
|
487
|
-
out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
|
|
488
667
|
if (csr) {
|
|
489
668
|
out += baseScript(nonce);
|
|
490
669
|
const publicEnv = getPublicDynamicEnv();
|
package/src/core/renderer.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
compress,
|
|
34
34
|
compressBytes,
|
|
35
35
|
encodeForRequest,
|
|
36
|
+
appHtmlHasTitle,
|
|
37
|
+
hasTitle,
|
|
36
38
|
isDev,
|
|
37
39
|
withPreloadLink,
|
|
38
40
|
type Encoded,
|
|
@@ -358,6 +360,14 @@ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
|
|
|
358
360
|
// Runs layout + page server loaders for a given URL.
|
|
359
361
|
// Used by both SSR and the /__bosia/data JSON endpoint.
|
|
360
362
|
//
|
|
363
|
+
// Every loader starts at once (SvelteKit's model). `parent()` is what orders
|
|
364
|
+
// them: it waits for the layers above and returns their merged data, so only a
|
|
365
|
+
// loader that asks for it waits. Results are still settled root → leaf, so the
|
|
366
|
+
// root-most failure decides the response, exactly as when they ran in turn.
|
|
367
|
+
// The cost: page code that runs before `await parent()` runs even when a layout
|
|
368
|
+
// guard redirects — its output never ships, but its side effects happen. Guard
|
|
369
|
+
// in hooks, or `await parent()` first.
|
|
370
|
+
//
|
|
361
371
|
// `mask` controls which layers' data the client data endpoint sends back:
|
|
362
372
|
// - undefined → send everything (SSR, first nav)
|
|
363
373
|
// - layouts[i] === false → the layout still RUNS, but its slot is emitted as
|
|
@@ -375,6 +385,8 @@ export type LoaderMask = {
|
|
|
375
385
|
layouts: boolean[];
|
|
376
386
|
};
|
|
377
387
|
|
|
388
|
+
type LayerResult = { data: Record<string, any>; deps: LoaderDeps };
|
|
389
|
+
|
|
378
390
|
export async function loadRouteData(
|
|
379
391
|
url: URL,
|
|
380
392
|
locals: Record<string, any>,
|
|
@@ -392,25 +404,61 @@ export async function loadRouteData(
|
|
|
392
404
|
const origin = url.origin;
|
|
393
405
|
const layoutData: (Record<string, any> | null)[] = [];
|
|
394
406
|
const layoutDeps: (LoaderDeps | null)[] = [];
|
|
395
|
-
let parentData: Record<string, any> = {};
|
|
396
407
|
// Shared across layout + page loaders so cross-loader duplicate
|
|
397
408
|
// setHeaders() calls throw. Keys stored lowercased.
|
|
398
409
|
const loaderHeaders: Record<string, string> = {};
|
|
399
410
|
const setHeaders = makeSetHeaders(loaderHeaders);
|
|
400
411
|
|
|
401
|
-
//
|
|
412
|
+
// Merged data of the first `n` layout layers. A fresh object per call, so
|
|
413
|
+
// loaders cannot mutate what the others see.
|
|
414
|
+
const layers: Promise<LayerResult>[] = [];
|
|
415
|
+
const parentOf = (n: number) => async () => {
|
|
416
|
+
const above = await Promise.all(layers.slice(0, n));
|
|
417
|
+
return Object.assign({}, ...above.map((l) => l.data));
|
|
418
|
+
};
|
|
419
|
+
|
|
402
420
|
for (const ls of route.layoutServers) {
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
try {
|
|
421
|
+
const parent = parentOf(layers.length);
|
|
422
|
+
const layer = (async (): Promise<LayerResult> => {
|
|
406
423
|
const mod = await ls.loader();
|
|
407
|
-
if (typeof mod.load
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
424
|
+
if (typeof mod.load !== "function" || warmingUp) return { data: {}, deps: emptyDeps() };
|
|
425
|
+
const deps = emptyDeps();
|
|
426
|
+
const data =
|
|
427
|
+
(await withTimeout(
|
|
428
|
+
mod.load({
|
|
429
|
+
params: trackedParams(params, deps),
|
|
430
|
+
route: { id: route.id },
|
|
431
|
+
url: trackedUrl(url, deps),
|
|
432
|
+
locals,
|
|
433
|
+
cookies: trackedCookies(cookies, deps),
|
|
434
|
+
parent,
|
|
435
|
+
fetch: trackedFetch(fetch, origin, deps),
|
|
436
|
+
metadata: null,
|
|
437
|
+
depends: makeDepends(deps),
|
|
438
|
+
platform: getPlatform(),
|
|
439
|
+
setHeaders,
|
|
440
|
+
}),
|
|
441
|
+
LOAD_TIMEOUT,
|
|
442
|
+
`layout load (depth=${ls.depth}, ${url.pathname})`,
|
|
443
|
+
)) ?? {};
|
|
444
|
+
return { data, deps };
|
|
445
|
+
})();
|
|
446
|
+
// Settled in order below; a rejection must not be reported as unhandled
|
|
447
|
+
// while an earlier layer is still being awaited.
|
|
448
|
+
layer.catch(() => {});
|
|
449
|
+
layers.push(layer);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const skipPage = mask && mask.page === false;
|
|
453
|
+
const pageP = route.pageServer
|
|
454
|
+
? (async () => {
|
|
455
|
+
const mod = await route.pageServer!();
|
|
456
|
+
const flags = { csr: mod.csr !== false, ssr: mod.ssr !== false };
|
|
457
|
+
if (skipPage) return { ...flags, data: null, deps: null };
|
|
458
|
+
if (typeof mod.load !== "function" || warmingUp)
|
|
459
|
+
return { ...flags, data: {}, deps: emptyDeps() };
|
|
412
460
|
const deps = emptyDeps();
|
|
413
|
-
const
|
|
461
|
+
const data =
|
|
414
462
|
(await withTimeout(
|
|
415
463
|
mod.load({
|
|
416
464
|
params: trackedParams(params, deps),
|
|
@@ -418,23 +466,35 @@ export async function loadRouteData(
|
|
|
418
466
|
url: trackedUrl(url, deps),
|
|
419
467
|
locals,
|
|
420
468
|
cookies: trackedCookies(cookies, deps),
|
|
421
|
-
parent,
|
|
469
|
+
parent: parentOf(layers.length),
|
|
422
470
|
fetch: trackedFetch(fetch, origin, deps),
|
|
423
|
-
metadata:
|
|
471
|
+
metadata: await metadataData,
|
|
424
472
|
depends: makeDepends(deps),
|
|
425
473
|
platform: getPlatform(),
|
|
426
474
|
setHeaders,
|
|
427
475
|
}),
|
|
428
476
|
LOAD_TIMEOUT,
|
|
429
|
-
`
|
|
477
|
+
`page load (${url.pathname})`,
|
|
430
478
|
)) ?? {};
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
479
|
+
return {
|
|
480
|
+
...flags,
|
|
481
|
+
data: data as Record<string, any> | null,
|
|
482
|
+
deps: deps as LoaderDeps | null,
|
|
483
|
+
};
|
|
484
|
+
})()
|
|
485
|
+
: null;
|
|
486
|
+
pageP?.catch(() => {});
|
|
487
|
+
|
|
488
|
+
// Settle root → leaf. The first failure wins, carrying the data of the
|
|
489
|
+
// layers above it — the same error a serial run would have produced.
|
|
490
|
+
for (let i = 0; i < route.layoutServers.length; i++) {
|
|
491
|
+
const ls = route.layoutServers[i]!;
|
|
492
|
+
// Runs regardless of the mask — see the trust note above loadRouteData.
|
|
493
|
+
const omit = mask && mask.layouts[ls.depth] === false;
|
|
494
|
+
try {
|
|
495
|
+
const { data, deps } = await layers[i]!;
|
|
496
|
+
layoutData[ls.depth] = omit ? null : data;
|
|
497
|
+
layoutDeps[ls.depth] = omit ? null : deps;
|
|
438
498
|
} catch (err) {
|
|
439
499
|
if (isRedirect(err)) throw err;
|
|
440
500
|
if (isHttpError(err)) {
|
|
@@ -460,47 +520,13 @@ export async function loadRouteData(
|
|
|
460
520
|
}
|
|
461
521
|
}
|
|
462
522
|
|
|
463
|
-
|
|
464
|
-
let
|
|
465
|
-
let pageDeps: LoaderDeps | null = null;
|
|
523
|
+
let pageData: Record<string, any> | null = {};
|
|
524
|
+
let pageDeps: LoaderDeps | null = emptyDeps();
|
|
466
525
|
let csr = true;
|
|
467
526
|
let ssr = true;
|
|
468
|
-
|
|
469
|
-
if (route.pageServer) {
|
|
527
|
+
if (pageP) {
|
|
470
528
|
try {
|
|
471
|
-
|
|
472
|
-
if (mod.csr === false) csr = false;
|
|
473
|
-
if (mod.ssr === false) ssr = false;
|
|
474
|
-
if (skipPage) {
|
|
475
|
-
pageData = null;
|
|
476
|
-
pageDeps = null;
|
|
477
|
-
} else if (typeof mod.load === "function" && !warmingUp) {
|
|
478
|
-
const snapshot = { ...parentData };
|
|
479
|
-
const parent = async () => snapshot;
|
|
480
|
-
const deps = emptyDeps();
|
|
481
|
-
pageData =
|
|
482
|
-
(await withTimeout(
|
|
483
|
-
mod.load({
|
|
484
|
-
params: trackedParams(params, deps),
|
|
485
|
-
route: { id: route.id },
|
|
486
|
-
url: trackedUrl(url, deps),
|
|
487
|
-
locals,
|
|
488
|
-
cookies: trackedCookies(cookies, deps),
|
|
489
|
-
parent,
|
|
490
|
-
fetch: trackedFetch(fetch, origin, deps),
|
|
491
|
-
metadata: await metadataData,
|
|
492
|
-
depends: makeDepends(deps),
|
|
493
|
-
platform: getPlatform(),
|
|
494
|
-
setHeaders,
|
|
495
|
-
}),
|
|
496
|
-
LOAD_TIMEOUT,
|
|
497
|
-
`page load (${url.pathname})`,
|
|
498
|
-
)) ?? {};
|
|
499
|
-
pageDeps = deps;
|
|
500
|
-
} else {
|
|
501
|
-
pageData = {};
|
|
502
|
-
pageDeps = emptyDeps();
|
|
503
|
-
}
|
|
529
|
+
({ data: pageData, deps: pageDeps, csr, ssr } = await pageP);
|
|
504
530
|
} catch (err) {
|
|
505
531
|
if (isRedirect(err)) throw err;
|
|
506
532
|
if (isHttpError(err)) {
|
|
@@ -524,9 +550,6 @@ export async function loadRouteData(
|
|
|
524
550
|
);
|
|
525
551
|
throw wrapped;
|
|
526
552
|
}
|
|
527
|
-
} else {
|
|
528
|
-
pageData = {};
|
|
529
|
-
pageDeps = emptyDeps();
|
|
530
553
|
}
|
|
531
554
|
|
|
532
555
|
// `params` are always attached to pageData for client-side router consumption.
|
|
@@ -615,15 +638,24 @@ export async function renderSSRStream(
|
|
|
615
638
|
// into the cached HTML but the CSP header is re-derived each request, so a
|
|
616
639
|
// cached page would ship with a dead nonce and the browser would block its
|
|
617
640
|
// inline scripts. Operators who turn on CSP_DIRECTIVES forfeit the cache.
|
|
618
|
-
|
|
619
|
-
|
|
641
|
+
// HEAD reads the GET entry but never coalesces or writes: it isn't a leader
|
|
642
|
+
// that fills it.
|
|
643
|
+
const cacheRead =
|
|
644
|
+
CACHE_ENABLED &&
|
|
645
|
+
!CSP_ENABLED &&
|
|
646
|
+
!warmingUp &&
|
|
647
|
+
routeCacheable &&
|
|
648
|
+
(req.method === "GET" || req.method === "HEAD");
|
|
649
|
+
const cacheable = cacheRead && req.method === "GET";
|
|
620
650
|
let cacheKey: string | null = null;
|
|
621
651
|
let releaseMiss: (() => void) | null = null;
|
|
622
|
-
if (
|
|
652
|
+
if (cacheRead) {
|
|
623
653
|
cacheKey = computeCacheKey(url, req, cookies as CookieJar);
|
|
624
654
|
if (!cacheBypass) {
|
|
625
655
|
const hit = cacheGet(cacheKey);
|
|
626
656
|
if (hit) return serveCached(hit, req);
|
|
657
|
+
}
|
|
658
|
+
if (cacheable && !cacheBypass) {
|
|
627
659
|
// Miss coalescing: the first miss leads the build; concurrent misses
|
|
628
660
|
// wait, then re-check the cache. A waiter that still misses (leader
|
|
629
661
|
// skipped the write) builds independently — no re-leadering.
|
|
@@ -646,8 +678,7 @@ export async function renderSSRStream(
|
|
|
646
678
|
// ── Pre-stream phase: metadata(), loaders, module imports and plugin
|
|
647
679
|
// fragments all start together, and all settle before committing to a 200,
|
|
648
680
|
// so a Redirect/HttpError from any of them still gets a proper response.
|
|
649
|
-
//
|
|
650
|
-
// waits for metadata.data (see loadRouteData).
|
|
681
|
+
// The page load() alone waits for metadata.data (see loadRouteData).
|
|
651
682
|
const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
|
|
652
683
|
(value) => ({ ok: true as const, value }),
|
|
653
684
|
(err: unknown) => ({ ok: false as const, err }),
|
|
@@ -786,9 +817,8 @@ export async function renderSSRStream(
|
|
|
786
817
|
}
|
|
787
818
|
const html =
|
|
788
819
|
buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments, route.pattern) +
|
|
789
|
-
buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
|
|
820
|
+
buildMetadataChunk(metadata, { headExtras, segments: appHtmlSegments, nonce }) +
|
|
790
821
|
buildHtmlTail(
|
|
791
|
-
"",
|
|
792
822
|
"",
|
|
793
823
|
pageDataFull,
|
|
794
824
|
layoutDataFull,
|
|
@@ -857,10 +887,9 @@ export async function renderSSRStream(
|
|
|
857
887
|
appHtmlSegments,
|
|
858
888
|
data.csr ? route.pattern : undefined,
|
|
859
889
|
) +
|
|
860
|
-
buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
|
|
890
|
+
buildMetadataChunk(metadata, { headExtras, segments: appHtmlSegments, head, nonce }) +
|
|
861
891
|
buildHtmlTail(
|
|
862
892
|
body,
|
|
863
|
-
head,
|
|
864
893
|
pageDataFull,
|
|
865
894
|
layoutDataFull,
|
|
866
895
|
data.csr,
|
|
@@ -963,11 +992,34 @@ export async function renderPageWithFormData(
|
|
|
963
992
|
|
|
964
993
|
const { route, params } = match;
|
|
965
994
|
|
|
966
|
-
//
|
|
995
|
+
// Same overlap as the GET path: metadata() and the loaders start together,
|
|
996
|
+
// and only the page load() waits for metadata.data. A metadata redirect or
|
|
997
|
+
// error() still answers first.
|
|
998
|
+
const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
|
|
999
|
+
(value) => ({ ok: true as const, value }),
|
|
1000
|
+
(err: unknown) => ({ ok: false as const, err }),
|
|
1001
|
+
);
|
|
1002
|
+
const loadP = Promise.all([
|
|
1003
|
+
loadRouteData(
|
|
1004
|
+
url,
|
|
1005
|
+
locals,
|
|
1006
|
+
req,
|
|
1007
|
+
cookies,
|
|
1008
|
+
metadataP.then((m) => (m.ok ? (m.value?.data ?? null) : null)),
|
|
1009
|
+
match,
|
|
1010
|
+
),
|
|
1011
|
+
route.pageModule(),
|
|
1012
|
+
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
1013
|
+
]);
|
|
1014
|
+
// Settled by the early returns below without being read.
|
|
1015
|
+
loadP.catch(() => {});
|
|
1016
|
+
|
|
967
1017
|
let metadata: Metadata | null = null;
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1018
|
+
const meta = await metadataP;
|
|
1019
|
+
if (meta.ok) {
|
|
1020
|
+
metadata = meta.value;
|
|
1021
|
+
} else {
|
|
1022
|
+
const err = meta.err;
|
|
971
1023
|
if (isRedirect(err))
|
|
972
1024
|
return new Response(null, { status: err.status, headers: { Location: err.location } });
|
|
973
1025
|
if (isHttpError(err)) {
|
|
@@ -989,12 +1041,7 @@ export async function renderPageWithFormData(
|
|
|
989
1041
|
// Continue with null metadata — don't break the page for a metadata failure
|
|
990
1042
|
}
|
|
991
1043
|
|
|
992
|
-
|
|
993
|
-
const [data, pageMod, layoutMods] = await Promise.all([
|
|
994
|
-
loadRouteData(url, locals, req, cookies, metadata?.data ?? null, match),
|
|
995
|
-
route.pageModule(),
|
|
996
|
-
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
997
|
-
]);
|
|
1044
|
+
const [data, pageMod, layoutMods] = await loadP;
|
|
998
1045
|
|
|
999
1046
|
if (!data)
|
|
1000
1047
|
return renderErrorPage(
|
|
@@ -1019,13 +1066,6 @@ export async function renderPageWithFormData(
|
|
|
1019
1066
|
pluginRenderFragments("head", renderCtx),
|
|
1020
1067
|
pluginRenderFragments("bodyEnd", renderCtx),
|
|
1021
1068
|
]);
|
|
1022
|
-
// buildHtml has no headExtras slot; fold them in ahead of the SSR head, which
|
|
1023
|
-
// is where buildMetadataChunk puts them on the streaming path.
|
|
1024
|
-
const headWithExtras = (ssrHead: string) => {
|
|
1025
|
-
const extras = headExtras.filter(Boolean);
|
|
1026
|
-
return extras.length ? `${extras.join("\n ")}\n ${ssrHead}` : ssrHead;
|
|
1027
|
-
};
|
|
1028
|
-
|
|
1029
1069
|
// Form-action re-render always runs every loader (no client mask).
|
|
1030
1070
|
const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
|
|
1031
1071
|
const pageDataFull = data.pageData ?? {};
|
|
@@ -1038,7 +1078,7 @@ export async function renderPageWithFormData(
|
|
|
1038
1078
|
}
|
|
1039
1079
|
const html = buildHtml(
|
|
1040
1080
|
"",
|
|
1041
|
-
|
|
1081
|
+
"",
|
|
1042
1082
|
pageDataFull,
|
|
1043
1083
|
layoutDataFull,
|
|
1044
1084
|
true,
|
|
@@ -1052,6 +1092,7 @@ export async function renderPageWithFormData(
|
|
|
1052
1092
|
appHtmlSegments,
|
|
1053
1093
|
metadata,
|
|
1054
1094
|
route.pattern,
|
|
1095
|
+
headExtras,
|
|
1055
1096
|
);
|
|
1056
1097
|
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
1057
1098
|
}
|
|
@@ -1073,7 +1114,7 @@ export async function renderPageWithFormData(
|
|
|
1073
1114
|
|
|
1074
1115
|
const html = buildHtml(
|
|
1075
1116
|
body,
|
|
1076
|
-
|
|
1117
|
+
head,
|
|
1077
1118
|
pageDataFull,
|
|
1078
1119
|
layoutDataFull,
|
|
1079
1120
|
data.csr,
|
|
@@ -1087,6 +1128,7 @@ export async function renderPageWithFormData(
|
|
|
1087
1128
|
appHtmlSegments,
|
|
1088
1129
|
metadata,
|
|
1089
1130
|
route.pattern,
|
|
1131
|
+
headExtras,
|
|
1090
1132
|
);
|
|
1091
1133
|
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
1092
1134
|
}
|
|
@@ -1115,9 +1157,13 @@ export async function renderErrorPage(
|
|
|
1115
1157
|
// The route's own metadata() is deliberately NOT run here — the route may be
|
|
1116
1158
|
// what failed, and a 404 has no route at all. Synthesize a status title only
|
|
1117
1159
|
// when the error component didn't set one, so <svelte:head><title> in a custom
|
|
1118
|
-
// +error.svelte still wins.
|
|
1160
|
+
// +error.svelte still wins. It is the late fallback (after the component's
|
|
1161
|
+
// head, so a real title hasTitle misses still comes first), except when
|
|
1162
|
+
// app.html has its own <title>: that would come first, so the status title
|
|
1163
|
+
// takes the metadata slot ahead of it instead.
|
|
1164
|
+
const statusTitle = `${status} — ${message}`;
|
|
1119
1165
|
const errMeta = (head: string): Metadata | null =>
|
|
1120
|
-
|
|
1166
|
+
appHtmlHasTitle(appHtmlSegments) && !hasTitle(head) ? { title: statusTitle } : null;
|
|
1121
1167
|
|
|
1122
1168
|
// Inspector overlay and other plugin bodyEnd fragments must be injected
|
|
1123
1169
|
// on error pages too — otherwise SSE never connects and runtime errors
|
|
@@ -1126,7 +1172,7 @@ export async function renderErrorPage(
|
|
|
1126
1172
|
request: req,
|
|
1127
1173
|
url,
|
|
1128
1174
|
route: route ? { pattern: route.pattern } : { pattern: "" },
|
|
1129
|
-
metadata:
|
|
1175
|
+
metadata: { title: statusTitle },
|
|
1130
1176
|
};
|
|
1131
1177
|
const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
|
|
1132
1178
|
|
|
@@ -1183,6 +1229,9 @@ export async function renderErrorPage(
|
|
|
1183
1229
|
bodyEndExtras,
|
|
1184
1230
|
appHtmlSegments,
|
|
1185
1231
|
errMeta(head),
|
|
1232
|
+
undefined,
|
|
1233
|
+
undefined,
|
|
1234
|
+
statusTitle,
|
|
1186
1235
|
);
|
|
1187
1236
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
1188
1237
|
} catch (err) {
|
|
@@ -1221,6 +1270,9 @@ export async function renderErrorPage(
|
|
|
1221
1270
|
bodyEndExtras,
|
|
1222
1271
|
appHtmlSegments,
|
|
1223
1272
|
errMeta(head),
|
|
1273
|
+
undefined,
|
|
1274
|
+
undefined,
|
|
1275
|
+
statusTitle,
|
|
1224
1276
|
);
|
|
1225
1277
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
1226
1278
|
} catch (err) {
|
package/src/core/server.ts
CHANGED
|
@@ -404,33 +404,39 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
404
404
|
)
|
|
405
405
|
: undefined;
|
|
406
406
|
const runLoad = async () => {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
try {
|
|
412
|
-
const meta = await loadMetadata(
|
|
407
|
+
// metadata() runs alongside the loaders; the page load() gets its
|
|
408
|
+
// `data`, as on SSR. A failure stays non-fatal here.
|
|
409
|
+
const metaP = pageMatch
|
|
410
|
+
? loadMetadata(
|
|
413
411
|
pageMatch.route,
|
|
414
412
|
pageMatch.params,
|
|
415
413
|
routeUrl,
|
|
416
414
|
locals,
|
|
417
415
|
cookies,
|
|
418
416
|
request,
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
417
|
+
).catch(() => null)
|
|
418
|
+
: Promise.resolve(null);
|
|
419
|
+
const data = await loadRouteData(
|
|
420
|
+
routeUrl,
|
|
421
|
+
locals,
|
|
422
|
+
request,
|
|
423
|
+
cookies,
|
|
424
|
+
metaP.then((m) => m?.data ?? null),
|
|
425
|
+
pageMatch,
|
|
426
|
+
mask,
|
|
427
|
+
);
|
|
428
|
+
const meta = await metaP;
|
|
429
|
+
// Explicit whitelist, not a spread: `metadata.data` feeds load() on the
|
|
430
|
+
// server and may hold secrets — it must not reach the client.
|
|
431
|
+
const metadata = meta
|
|
432
|
+
? {
|
|
433
|
+
title: meta.title,
|
|
434
|
+
description: meta.description,
|
|
435
|
+
meta: meta.meta,
|
|
436
|
+
link: meta.link,
|
|
437
|
+
lang: meta.lang,
|
|
438
|
+
}
|
|
439
|
+
: null;
|
|
434
440
|
|
|
435
441
|
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
436
442
|
};
|
|
@@ -606,15 +612,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
606
612
|
let releaseApiMiss: (() => void) | null = null;
|
|
607
613
|
try {
|
|
608
614
|
const mod = await apiMatch.route.module();
|
|
609
|
-
|
|
615
|
+
// HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
|
|
616
|
+
// strips the body on the way out, for every route kind.
|
|
617
|
+
const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
|
|
610
618
|
|
|
611
619
|
if (!handler) {
|
|
612
|
-
const allowed = Object.keys(mod)
|
|
613
|
-
|
|
614
|
-
.join(", ");
|
|
620
|
+
const allowed = Object.keys(mod).filter((k) => /^[A-Z]+$/.test(k));
|
|
621
|
+
if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
|
|
615
622
|
return Response.json(
|
|
616
623
|
{ error: `Method ${method} not allowed` },
|
|
617
|
-
{ status: 405, headers: { Allow: allowed } },
|
|
624
|
+
{ status: 405, headers: { Allow: allowed.join(", ") } },
|
|
618
625
|
);
|
|
619
626
|
}
|
|
620
627
|
|
|
@@ -623,15 +630,23 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
623
630
|
// ── Response cache for +server.ts GET handlers ──
|
|
624
631
|
// CSP is skipped because cached responses would ship with a stale
|
|
625
632
|
// nonce (see renderer.ts for the same gate). The cache key includes
|
|
626
|
-
// URL + identity so per-user responses stay isolated.
|
|
627
|
-
|
|
628
|
-
|
|
633
|
+
// URL + identity so per-user responses stay isolated. HEAD reads the GET
|
|
634
|
+
// entry but never coalesces or writes: it isn't a leader that fills it.
|
|
635
|
+
// An explicit HEAD export skips the cache so it always runs.
|
|
636
|
+
const apiCacheRead =
|
|
637
|
+
CACHE_ENABLED &&
|
|
638
|
+
!CSP_ENABLED &&
|
|
639
|
+
(mod as any).cache !== false &&
|
|
640
|
+
(method === "GET" || (method === "HEAD" && !mod.HEAD));
|
|
641
|
+
const apiCacheable = apiCacheRead && method === "GET";
|
|
629
642
|
let apiCacheKey: string | null = null;
|
|
630
|
-
if (
|
|
643
|
+
if (apiCacheRead) {
|
|
631
644
|
apiCacheKey = computeCacheKey(url, request, cookies as CookieJar);
|
|
632
645
|
if (!url.searchParams.has("_invalidated")) {
|
|
633
646
|
const hit = cacheGet(apiCacheKey);
|
|
634
647
|
if (hit) return serveCached(hit, request);
|
|
648
|
+
}
|
|
649
|
+
if (apiCacheable && !url.searchParams.has("_invalidated")) {
|
|
635
650
|
// Miss coalescing: first miss runs the handler; concurrent misses
|
|
636
651
|
// wait, re-check the cache, and on a still-miss run independently.
|
|
637
652
|
const gate = coalesceMiss(apiCacheKey);
|
|
@@ -1032,6 +1047,69 @@ if (_xfoDisabled) {
|
|
|
1032
1047
|
console.log("🪟 X-Frame-Options disabled (DISABLE_X_FRAME_OPTIONS=true)");
|
|
1033
1048
|
}
|
|
1034
1049
|
|
|
1050
|
+
// Security headers, CSP, CORS and the request's Set-Cookie headers, applied to
|
|
1051
|
+
// every response handleRequest hands back — resolved ones and the ones a hook
|
|
1052
|
+
// threw (redirect / error()).
|
|
1053
|
+
function finalizeResponse(
|
|
1054
|
+
response: Response,
|
|
1055
|
+
request: Request,
|
|
1056
|
+
nonce: string,
|
|
1057
|
+
cookieJar: CookieJar,
|
|
1058
|
+
): Response {
|
|
1059
|
+
// Set on the response itself when its headers are mutable, which every
|
|
1060
|
+
// Response the framework builds has. Copying them and rebuilding the
|
|
1061
|
+
// Response cost a Headers copy plus a body re-wrap on every request.
|
|
1062
|
+
// Responses with immutable headers (`fetch()`, `Response.redirect()`)
|
|
1063
|
+
// still get the copy.
|
|
1064
|
+
// On Workers (compressionOn is false only there) an already-encoded body
|
|
1065
|
+
// is rebuilt as before, since the rebuild is what marks it PRECOMPRESSED.
|
|
1066
|
+
let mutable: Headers | null = null;
|
|
1067
|
+
if (compressionOn || !response.headers.has("content-encoding")) {
|
|
1068
|
+
try {
|
|
1069
|
+
response.headers.set("X-Content-Type-Options", SECURITY_HEADERS["X-Content-Type-Options"]!);
|
|
1070
|
+
mutable = response.headers;
|
|
1071
|
+
} catch {
|
|
1072
|
+
// immutable — copied below
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
const inPlace = mutable !== null;
|
|
1076
|
+
const headers = mutable ?? new Headers(response.headers);
|
|
1077
|
+
// A handle can mark a response (e.g. a proxied embeddable preview) to opt
|
|
1078
|
+
// out of the frame guard. Strip the internal marker so it never ships, and
|
|
1079
|
+
// skip only X-Frame-Options for that response — other security headers stay.
|
|
1080
|
+
const skipFrameGuard = headers.has(NO_FRAME_GUARD_HEADER);
|
|
1081
|
+
headers.delete(NO_FRAME_GUARD_HEADER);
|
|
1082
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
|
|
1083
|
+
if (skipFrameGuard && k === "X-Frame-Options") continue;
|
|
1084
|
+
headers.set(k, v);
|
|
1085
|
+
}
|
|
1086
|
+
const cspHeader = buildCspHeader(nonce);
|
|
1087
|
+
if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
|
|
1088
|
+
// Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
|
|
1089
|
+
// CORS is configured — even on responses to non-allowed origins — so
|
|
1090
|
+
// downstream caches (CDNs, browser HTTP cache) key on the Origin header
|
|
1091
|
+
// instead of serving an Access-Control-Allow-Origin response across origins.
|
|
1092
|
+
if (CORS_CONFIG) {
|
|
1093
|
+
applyCorsVary(headers);
|
|
1094
|
+
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
1095
|
+
if (corsHeaders) {
|
|
1096
|
+
for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// Apply any Set-Cookie headers accumulated during the request
|
|
1100
|
+
for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
|
|
1101
|
+
if (inPlace) return response;
|
|
1102
|
+
return new Response(response.body, {
|
|
1103
|
+
// A Content-Encoding here means the body is already compressed (cache
|
|
1104
|
+
// hit, compress()); rebuilding drops that flag and Workers would
|
|
1105
|
+
// compress it again.
|
|
1106
|
+
...(headers.has("content-encoding") ? PRECOMPRESSED : {}),
|
|
1107
|
+
status: response.status,
|
|
1108
|
+
statusText: response.statusText,
|
|
1109
|
+
headers,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1035
1113
|
async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
1036
1114
|
// Behind a trusted proxy the inbound `Host`/scheme is the proxy's inner hop
|
|
1037
1115
|
// (e.g. `localhost:PORT` over plain HTTP), so `url` built from `request.url`
|
|
@@ -1179,58 +1257,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1179
1257
|
}
|
|
1180
1258
|
}
|
|
1181
1259
|
|
|
1182
|
-
|
|
1183
|
-
// Response the framework builds has. Copying them and rebuilding the
|
|
1184
|
-
// Response cost a Headers copy plus a body re-wrap on every request.
|
|
1185
|
-
// Responses with immutable headers (`fetch()`, `Response.redirect()`)
|
|
1186
|
-
// still get the copy.
|
|
1187
|
-
// On Workers (compressionOn is false only there) an already-encoded body
|
|
1188
|
-
// is rebuilt as before, since the rebuild is what marks it PRECOMPRESSED.
|
|
1189
|
-
let mutable: Headers | null = null;
|
|
1190
|
-
if (compressionOn || !response.headers.has("content-encoding")) {
|
|
1191
|
-
try {
|
|
1192
|
-
response.headers.set("X-Content-Type-Options", SECURITY_HEADERS["X-Content-Type-Options"]!);
|
|
1193
|
-
mutable = response.headers;
|
|
1194
|
-
} catch {
|
|
1195
|
-
// immutable — copied below
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
const inPlace = mutable !== null;
|
|
1199
|
-
const headers = mutable ?? new Headers(response.headers);
|
|
1200
|
-
// A handle can mark a response (e.g. a proxied embeddable preview) to opt
|
|
1201
|
-
// out of the frame guard. Strip the internal marker so it never ships, and
|
|
1202
|
-
// skip only X-Frame-Options for that response — other security headers stay.
|
|
1203
|
-
const skipFrameGuard = headers.has(NO_FRAME_GUARD_HEADER);
|
|
1204
|
-
headers.delete(NO_FRAME_GUARD_HEADER);
|
|
1205
|
-
for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
|
|
1206
|
-
if (skipFrameGuard && k === "X-Frame-Options") continue;
|
|
1207
|
-
headers.set(k, v);
|
|
1208
|
-
}
|
|
1209
|
-
const cspHeader = buildCspHeader(nonce);
|
|
1210
|
-
if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
|
|
1211
|
-
// Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
|
|
1212
|
-
// CORS is configured — even on responses to non-allowed origins — so
|
|
1213
|
-
// downstream caches (CDNs, browser HTTP cache) key on the Origin header
|
|
1214
|
-
// instead of serving an Access-Control-Allow-Origin response across origins.
|
|
1215
|
-
if (CORS_CONFIG) {
|
|
1216
|
-
applyCorsVary(headers);
|
|
1217
|
-
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
1218
|
-
if (corsHeaders) {
|
|
1219
|
-
for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
|
|
1220
|
-
}
|
|
1221
|
-
}
|
|
1222
|
-
// Apply any Set-Cookie headers accumulated during the request
|
|
1223
|
-
for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
|
|
1224
|
-
if (inPlace) return response;
|
|
1225
|
-
return new Response(response.body, {
|
|
1226
|
-
// A Content-Encoding here means the body is already compressed (cache
|
|
1227
|
-
// hit, compress()); rebuilding drops that flag and Workers would
|
|
1228
|
-
// compress it again.
|
|
1229
|
-
...(headers.has("content-encoding") ? PRECOMPRESSED : {}),
|
|
1230
|
-
status: response.status,
|
|
1231
|
-
statusText: response.statusText,
|
|
1232
|
-
headers,
|
|
1233
|
-
});
|
|
1260
|
+
return finalizeResponse(response, request, nonce, cookieJar);
|
|
1234
1261
|
} catch (err) {
|
|
1235
1262
|
// `throw redirect()` / `throw error()` from a hook lands here — the same
|
|
1236
1263
|
// escape hatch loaders have always had. Without these branches both fall
|
|
@@ -1264,13 +1291,10 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1264
1291
|
undefined,
|
|
1265
1292
|
nonce,
|
|
1266
1293
|
);
|
|
1267
|
-
//
|
|
1268
|
-
// Set-Cookie
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1271
|
-
for (const cookie of cookieJar.outgoing) out.headers.append("Set-Cookie", cookie);
|
|
1272
|
-
}
|
|
1273
|
-
return out;
|
|
1294
|
+
// The same finishing pass as a resolved response: security headers,
|
|
1295
|
+
// CSP, CORS, and the jar's Set-Cookie — a hook that expires the session
|
|
1296
|
+
// before throwing must not lose the cookie that does it.
|
|
1297
|
+
return cookieJar ? finalizeResponse(out, request, nonce, cookieJar) : out;
|
|
1274
1298
|
}
|
|
1275
1299
|
if (isDev) console.error("Unhandled request error:", err);
|
|
1276
1300
|
else console.error("Unhandled request error:", (err as Error).message ?? err);
|
package/src/lib/utils.ts
CHANGED
|
@@ -3,14 +3,7 @@ import { twMerge } from "tailwind-merge";
|
|
|
3
3
|
type ClassDictionary = Record<string, any>;
|
|
4
4
|
type ClassArray = ClassValue[];
|
|
5
5
|
type ClassValue =
|
|
6
|
-
|
|
|
7
|
-
| ClassDictionary
|
|
8
|
-
| string
|
|
9
|
-
| number
|
|
10
|
-
| bigint
|
|
11
|
-
| null
|
|
12
|
-
| boolean
|
|
13
|
-
| undefined;
|
|
6
|
+
ClassArray | ClassDictionary | string | number | bigint | null | boolean | undefined;
|
|
14
7
|
|
|
15
8
|
function clsx(...inputs: ClassValue[]): string {
|
|
16
9
|
let str = "";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: uploaded files (the file-upload feature). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Files</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Files</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: list orders and open their details (OrderService in src/features/shop). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Orders</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Orders</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: orders waiting to be paid or shipped. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Pending orders</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Pending orders</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: refund requests and refunded orders. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Refunds</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Refunds</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: list, create and edit products (ProductService in src/features/shop). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Products</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Products</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: the signed-in user's name, email and password. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Profile</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Profile</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: store settings. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Settings</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Settings</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: users and their roles (the auth and rbac features). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Users</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Users</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: uploaded files (the file-upload feature). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Files</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Files</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: list orders and open their details (OrderService in src/features/shop). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Orders</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Orders</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: orders waiting to be paid or shipped. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Pending orders</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Pending orders</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: refund requests and refunded orders. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Refunds</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Refunds</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: list, create and edit products (ProductService in src/features/shop). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Products</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Products</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: the signed-in user's name, email and password. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Profile</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Profile</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: store settings. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Settings</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Settings</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: users and their roles (the auth and rbac features). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Users</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Users</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|