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.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/ambient.d.ts +3 -0
- package/src/cli/sync.ts +14 -1
- package/src/core/appHtml.ts +52 -0
- package/src/core/build.ts +4 -3
- package/src/core/client/App.svelte +82 -12
- package/src/core/client/TitleProbe.svelte +15 -0
- package/src/core/client/router.svelte.ts +1 -2
- package/src/core/client/titleMemory.svelte.ts +64 -0
- package/src/core/headScan.ts +132 -0
- package/src/core/html.ts +103 -45
- package/src/core/renderer.ts +190 -91
- package/src/core/routeFile.ts +15 -2
- package/src/core/scanner.ts +28 -31
- package/src/core/server.ts +95 -80
- 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/shop/src/routes/(public)/+page.svelte +2 -2
- 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/templates/store/src/routes/(public)/+page.svelte +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bosapi and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
package/package.json
CHANGED
package/src/ambient.d.ts
CHANGED
package/src/cli/sync.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { scanRoutes, RouteConflictError } from "../core/scanner.ts";
|
|
2
2
|
import { generateRoutesFile } from "../core/routeFile.ts";
|
|
3
|
+
import { clientDefaultTitle, loadAppHtmlTemplate } from "../core/appHtml.ts";
|
|
4
|
+
import { DEFAULT_TITLE } from "../core/headScan.ts";
|
|
3
5
|
import { generateRouteTypes, ensureRootDirs } from "../core/routeTypes.ts";
|
|
4
6
|
import { loadEnv, classifyEnvVars } from "../core/env.ts";
|
|
5
7
|
import { generateEnvModules } from "../core/envCodegen.ts";
|
|
@@ -16,7 +18,7 @@ export async function runSync() {
|
|
|
16
18
|
console.error(`❌ ${err.message}`);
|
|
17
19
|
process.exit(1);
|
|
18
20
|
}
|
|
19
|
-
generateRoutesFile(manifest);
|
|
21
|
+
generateRoutesFile(manifest, syncDefaultTitle());
|
|
20
22
|
generateRouteTypes(manifest);
|
|
21
23
|
ensureRootDirs();
|
|
22
24
|
generateEnvModules(classifiedEnv);
|
|
@@ -32,3 +34,14 @@ export async function runSync() {
|
|
|
32
34
|
process.exit(1);
|
|
33
35
|
}
|
|
34
36
|
}
|
|
37
|
+
|
|
38
|
+
/** app.html's title, as build passes it. A missing or broken app.html is the
|
|
39
|
+
* build's error to report; sync still writes the routes. */
|
|
40
|
+
function syncDefaultTitle(): string {
|
|
41
|
+
try {
|
|
42
|
+
return clientDefaultTitle(loadAppHtmlTemplate());
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.warn(`⚠️ Could not read the app.html title, using "${DEFAULT_TITLE}":`, err);
|
|
45
|
+
return DEFAULT_TITLE;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/core/appHtml.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { OUT_DIR } from "./paths.ts";
|
|
|
5
5
|
import { readArtifact } from "./artifacts.ts";
|
|
6
6
|
import { rebaseHtmlAttrs } from "./basePath.ts";
|
|
7
7
|
import { currentBase } from "./appBase.ts";
|
|
8
|
+
import { DEFAULT_TITLE, hasTitle, titleText } from "./headScan.ts";
|
|
8
9
|
|
|
9
10
|
// ─── Types ────────────────────────────────────────────────
|
|
10
11
|
|
|
@@ -62,6 +63,57 @@ export function loadAppHtmlTemplate(cwd: string = process.cwd()): AppHtmlSegment
|
|
|
62
63
|
};
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
// Comments and <script>/<style> bodies are matched only to be skipped; the
|
|
67
|
+
// first real `</head …>` or, when the optional </head> is left out, `<body`
|
|
68
|
+
// is where the head ends.
|
|
69
|
+
const HEAD_END_RE =
|
|
70
|
+
/<!--[\s\S]*?(?:-->|$)|<(script|style)\b[\s\S]*?(?:<\/\1\s*>|$)|<\/head\s*>|<body[\s>/]/gi;
|
|
71
|
+
|
|
72
|
+
/** Index in headClose (which runs on to %bosia.body%) where the head ends, or -1.
|
|
73
|
+
* Matches case-insensitively on the original string: lowercasing a copy can
|
|
74
|
+
* change its length (`İ`) and shift the index. */
|
|
75
|
+
export function findHeadEnd(headClose: string): number {
|
|
76
|
+
for (const m of headClose.matchAll(HEAD_END_RE)) {
|
|
77
|
+
if (m[0].startsWith("<!--") || m[1]) continue;
|
|
78
|
+
return m.index;
|
|
79
|
+
}
|
|
80
|
+
return -1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type HeadSplit = { before: string; after: string; hasTitle: boolean };
|
|
84
|
+
|
|
85
|
+
// app.html never changes after it is parsed, so split it and check it for a
|
|
86
|
+
// title once per segments object instead of on every render. Kept in memory,
|
|
87
|
+
// not in dist/app-html.json, so it can't go stale against headClose.
|
|
88
|
+
const headSplits = new WeakMap<AppHtmlSegments, HeadSplit>();
|
|
89
|
+
|
|
90
|
+
/** headClose split at the end of the head: `before` still belongs to <head>,
|
|
91
|
+
* `after` runs on to %bosia.body%. Also whether app.html's head sets a title. */
|
|
92
|
+
export function headSplit(segments: AppHtmlSegments): HeadSplit {
|
|
93
|
+
let split = headSplits.get(segments);
|
|
94
|
+
if (!split) {
|
|
95
|
+
const { headOpen, headClose } = segments;
|
|
96
|
+
const i = findHeadEnd(headClose);
|
|
97
|
+
const before = i < 0 ? "" : headClose.slice(0, i);
|
|
98
|
+
const after = i < 0 ? headClose : headClose.slice(i);
|
|
99
|
+
split = { before, after, hasTitle: hasTitle(headOpen) || hasTitle(before) };
|
|
100
|
+
headSplits.set(segments, split);
|
|
101
|
+
}
|
|
102
|
+
return split;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The raw text of app.html's own <title>, or null when it has none. Looks in
|
|
106
|
+
* the same places as headSplit, so it finds the title SSR keeps. */
|
|
107
|
+
export function appHtmlTitle(segments: AppHtmlSegments): string | null {
|
|
108
|
+
return titleText(segments.headOpen) ?? titleText(headSplit(segments).before);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** What client navigation calls a page with no title anywhere: app.html's
|
|
112
|
+
* <title>, else "Bosia App" — as SSR does. Raw text; the browser decodes it. */
|
|
113
|
+
export function clientDefaultTitle(segments: AppHtmlSegments): string {
|
|
114
|
+
return appHtmlTitle(segments) ?? DEFAULT_TITLE;
|
|
115
|
+
}
|
|
116
|
+
|
|
65
117
|
function replaceStaticPlaceholders(template: string): string {
|
|
66
118
|
// Replace %bosia.assets% with BOSIA_ASSETS_BASE env var
|
|
67
119
|
const assetsBase = process.env.BOSIA_ASSETS_BASE || "";
|
package/src/core/build.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { currentBase } from "./appBase.ts";
|
|
|
27
27
|
import { finalizeTailwindCss, TW_TEMP_BASENAME } from "./twHash.ts";
|
|
28
28
|
import { loadBosiaConfig, loadPlugins } from "./config.ts";
|
|
29
29
|
import type { BuildContext, RuntimeTarget } from "./types/plugin.ts";
|
|
30
|
-
import { loadAppHtmlTemplate, writeAppHtmlSegments } from "./appHtml.ts";
|
|
30
|
+
import { clientDefaultTitle, loadAppHtmlTemplate, writeAppHtmlSegments } from "./appHtml.ts";
|
|
31
31
|
import {
|
|
32
32
|
generateArtifactsModule,
|
|
33
33
|
generateWorkersRuntime,
|
|
@@ -189,8 +189,9 @@ for (const p of userPlugins) {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// 2. Generate .bosia/routes.ts (single file replaces all old code generators)
|
|
193
|
-
|
|
192
|
+
// 2. Generate .bosia/routes.ts (single file replaces all old code generators).
|
|
193
|
+
// The client names a page with no title after app.html, as SSR does.
|
|
194
|
+
generateRoutesFile(manifest, clientDefaultTitle(appHtml));
|
|
194
195
|
|
|
195
196
|
// 2b. Generate .bosia/types/src/routes/**/$types.d.ts for IDE type inference
|
|
196
197
|
generateRouteTypes(manifest);
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
import { tick } from "svelte";
|
|
3
3
|
import { router, scrollToHash } from "./router.svelte.ts";
|
|
4
4
|
import { findMatch } from "../matcher.ts";
|
|
5
|
-
import { clientRoutes } from "bosia:routes";
|
|
5
|
+
import { clientRoutes, defaultTitle } from "bosia:routes";
|
|
6
6
|
import { consumePrefetch, prefetchCache, dataUrl, readDataResponse } from "./prefetch.ts";
|
|
7
7
|
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
8
8
|
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
9
9
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
10
10
|
import { fireAfterNavigate, type Navigation } from "./navListeners.ts";
|
|
11
11
|
import { drainNavResolvers } from "./navigation.ts";
|
|
12
|
+
import { collapseTitle } from "../headScan.ts";
|
|
13
|
+
import { chainTitles, takeTitleWrite, titleNav } from "./titleMemory.svelte.ts";
|
|
14
|
+
import TitleProbe from "./TitleProbe.svelte";
|
|
12
15
|
|
|
13
16
|
let {
|
|
14
17
|
ssrMode = false,
|
|
@@ -39,6 +42,43 @@
|
|
|
39
42
|
let pageInstance = $state<any>(null);
|
|
40
43
|
if (!ssrMode) router.getPageSnapshot = () => pageInstance?.snapshot;
|
|
41
44
|
|
|
45
|
+
// The last title navigation (or SSR's metadata()) wrote, as document.title
|
|
46
|
+
// reads it. Same page, same title still → no component took it over, so
|
|
47
|
+
// navigation may replace it again.
|
|
48
|
+
let navTitle: string | null = null;
|
|
49
|
+
|
|
50
|
+
// Build-time titles are raw HTML text; a <textarea> decodes every entity the
|
|
51
|
+
// way the browser did on a hard load. Trusted Types block the innerHTML
|
|
52
|
+
// write, so the raw text is the fallback there.
|
|
53
|
+
const decoded = new Map<string, string>();
|
|
54
|
+
function decodeTitle(raw: string): string {
|
|
55
|
+
let text = decoded.get(raw);
|
|
56
|
+
if (text === undefined) {
|
|
57
|
+
try {
|
|
58
|
+
const t = document.createElement("textarea");
|
|
59
|
+
t.innerHTML = raw;
|
|
60
|
+
text = collapseTitle(t.value);
|
|
61
|
+
} catch {
|
|
62
|
+
text = collapseTitle(raw);
|
|
63
|
+
}
|
|
64
|
+
decoded.set(raw, text);
|
|
65
|
+
}
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The title a page with none of its own shows under the first `depth`
|
|
70
|
+
* layouts: the deepest one's that wrote one (TitleProbe), else app.html's. */
|
|
71
|
+
function baseTitle(depth: number): string {
|
|
72
|
+
return (depth > 0 ? chainTitles[depth - 1] : null) ?? decodeTitle(defaultTitle);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How many leading layouts two routes share, by file path. */
|
|
76
|
+
function sharedDepth(a: string[], b: string[]): number {
|
|
77
|
+
let s = 0;
|
|
78
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++;
|
|
79
|
+
return s;
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
// Call the landed-on page's snapshot.restore() once it's mounted (post-tick).
|
|
43
83
|
function settleSnapshot() {
|
|
44
84
|
const snap = router.pendingSnapshot;
|
|
@@ -104,6 +144,15 @@
|
|
|
104
144
|
settleSnapshot();
|
|
105
145
|
}
|
|
106
146
|
|
|
147
|
+
// Each flush a navigation starts begins here, before any head effect: what
|
|
148
|
+
// was written since — navigation's own title, an afterNavigate listener's,
|
|
149
|
+
// the last page's — is no layout's, so the probes must not take it.
|
|
150
|
+
$effect.pre(() => {
|
|
151
|
+
if (ssrMode) return;
|
|
152
|
+
titleNav.seq;
|
|
153
|
+
takeTitleWrite();
|
|
154
|
+
});
|
|
155
|
+
|
|
107
156
|
$effect(() => {
|
|
108
157
|
if (ssrMode) return;
|
|
109
158
|
|
|
@@ -124,6 +173,11 @@
|
|
|
124
173
|
firstNav = false;
|
|
125
174
|
if (isFirst) {
|
|
126
175
|
currentLayoutPaths = (match.route as any).layoutPaths ?? [];
|
|
176
|
+
// The probes ran during hydration, before this effect. A title other than
|
|
177
|
+
// metadata()'s or the layouts' was a component's, so navigation keeps it.
|
|
178
|
+
const ssrTitle: string | undefined = (window as any).__BOSIA_TITLE__;
|
|
179
|
+
const base = baseTitle(currentLayoutPaths.length);
|
|
180
|
+
navTitle = ssrTitle ? collapseTitle(ssrTitle) : document.title === base ? base : null;
|
|
127
181
|
lastSettledPath = pathname;
|
|
128
182
|
lastNavKey = navKey;
|
|
129
183
|
lastTick = currentTick;
|
|
@@ -162,13 +216,7 @@
|
|
|
162
216
|
const destLayoutPaths = ((match.route as any).layoutPaths as string[]) ?? [];
|
|
163
217
|
const loadingImport = (match.route as any).loading as (() => Promise<any>) | null;
|
|
164
218
|
if (loadingImport && pathname !== lastSettledPath) {
|
|
165
|
-
|
|
166
|
-
while (
|
|
167
|
-
s < currentLayoutPaths.length &&
|
|
168
|
-
s < destLayoutPaths.length &&
|
|
169
|
-
currentLayoutPaths[s] === destLayoutPaths[s]
|
|
170
|
-
)
|
|
171
|
-
s++;
|
|
219
|
+
const s = sharedDepth(currentLayoutPaths, destLayoutPaths);
|
|
172
220
|
loadingImport().then((m) => {
|
|
173
221
|
if (cancelled) return; // superseded nav
|
|
174
222
|
loadingDepth = s;
|
|
@@ -259,9 +307,11 @@
|
|
|
259
307
|
// Tear down the loading skeleton and record identity for the next nav.
|
|
260
308
|
// Superseded navs have cancelled === true and never reach here, so they
|
|
261
309
|
// can't clobber a newer nav's loader.
|
|
310
|
+
// The skeleton and the error page render in their own {#if} branch, so
|
|
311
|
+
// leaving either remounts the page. Read before both are cleared below.
|
|
312
|
+
const pageShown = !showLoading && !appState.errorComponent;
|
|
262
313
|
showLoading = false;
|
|
263
314
|
LoadingComponent = null;
|
|
264
|
-
currentLayoutPaths = destLayoutPaths;
|
|
265
315
|
lastSettledPath = pathname;
|
|
266
316
|
lastNavKey = navKey;
|
|
267
317
|
navDoneTimer = setTimeout(() => {
|
|
@@ -301,6 +351,8 @@
|
|
|
301
351
|
]);
|
|
302
352
|
if (cancelled) return;
|
|
303
353
|
layoutComponents = layoutModsForError.map((m: any) => m.default);
|
|
354
|
+
currentLayoutPaths = destLayoutPaths.slice(0, K);
|
|
355
|
+
titleNav.seq++; // the error branch remounts the layouts
|
|
304
356
|
const newLayoutData: Record<string, any>[] = [];
|
|
305
357
|
for (let i = 0; i < K; i++) newLayoutData.push({});
|
|
306
358
|
appState.layoutData = newLayoutData;
|
|
@@ -316,6 +368,11 @@
|
|
|
316
368
|
}
|
|
317
369
|
return;
|
|
318
370
|
}
|
|
371
|
+
// Layout paths are recorded only once they render: a redirect above
|
|
372
|
+
// leaves the mounted ones in place.
|
|
373
|
+
const shared = sharedDepth(currentLayoutPaths, destLayoutPaths);
|
|
374
|
+
currentLayoutPaths = destLayoutPaths;
|
|
375
|
+
const pageChanged = !pageShown || PageComponent !== pageMod.default;
|
|
319
376
|
PageComponent = pageMod.default;
|
|
320
377
|
layoutComponents = layoutMods.map((m: any) => m.default);
|
|
321
378
|
|
|
@@ -408,9 +465,19 @@
|
|
|
408
465
|
if (result || !match.route.hasServerData) {
|
|
409
466
|
const md = result?.metadata;
|
|
410
467
|
document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
|
|
411
|
-
//
|
|
412
|
-
// the
|
|
413
|
-
|
|
468
|
+
// With no metadata() title, a page gets the one a hard load would give
|
|
469
|
+
// it: the layouts' (baseTitle), else app.html's <title>, else "Bosia
|
|
470
|
+
// App". A layout that stays mounted never writes its title again, so it
|
|
471
|
+
// is put back here. Set before Svelte flushes, so a freshly mounted
|
|
472
|
+
// <svelte:head><title> still wins. On the same page (invalidate, a form
|
|
473
|
+
// action, ?page=2) it stays once a component like <Seo> took it over.
|
|
474
|
+
if (md?.title) {
|
|
475
|
+
document.title = md.title;
|
|
476
|
+
navTitle = document.title;
|
|
477
|
+
} else if (pageChanged || document.title === navTitle) {
|
|
478
|
+
document.title = baseTitle(shared);
|
|
479
|
+
navTitle = document.title;
|
|
480
|
+
}
|
|
414
481
|
if (md?.lang) document.documentElement.lang = md.lang;
|
|
415
482
|
const emit = (tag: "meta" | "link", attrs: Record<string, string | undefined>) => {
|
|
416
483
|
const el = document.createElement(tag);
|
|
@@ -424,6 +491,7 @@
|
|
|
424
491
|
for (const l of md?.link ?? [])
|
|
425
492
|
emit("link", { rel: l.rel, href: l.href, hreflang: l.hreflang });
|
|
426
493
|
}
|
|
494
|
+
titleNav.seq++; // the probes read again as this flushes
|
|
427
495
|
|
|
428
496
|
settle({ url, params: match.params });
|
|
429
497
|
});
|
|
@@ -480,10 +548,12 @@
|
|
|
480
548
|
|
|
481
549
|
{#if index < leafDepth - 1}
|
|
482
550
|
<Layout {data} {params}>
|
|
551
|
+
<TitleProbe depth={index} layout={Layout} />
|
|
483
552
|
{@render renderLayout(index + 1, leafDepth)}
|
|
484
553
|
</Layout>
|
|
485
554
|
{:else}
|
|
486
555
|
<Layout {data} {params}>
|
|
556
|
+
<TitleProbe depth={index} layout={Layout} />
|
|
487
557
|
{#if ErrorComponent}
|
|
488
558
|
<ErrorComponent {...errorProps ?? {}} {params} />
|
|
489
559
|
{:else if PageComponent}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { chainTitles, layoutTitles, takeTitleWrite, titleNav } from "./titleMemory.svelte.ts";
|
|
3
|
+
|
|
4
|
+
// Sits after layout `depth`, before what it wraps, and takes the title that
|
|
5
|
+
// layout wrote. `layout` is the layout component, the key it is remembered by.
|
|
6
|
+
let { depth, layout }: { depth: number; layout: unknown } = $props();
|
|
7
|
+
|
|
8
|
+
$effect(() => {
|
|
9
|
+
titleNav.seq; // read again on every navigation
|
|
10
|
+
const w = takeTitleWrite();
|
|
11
|
+
if (w !== null) layoutTitles.set(layout, w);
|
|
12
|
+
chainTitles[depth] =
|
|
13
|
+
layoutTitles.get(layout) ?? (depth > 0 ? (chainTitles[depth - 1] ?? null) : null);
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
@@ -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
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The title each layout depth shows, read at run time. Svelte writes a
|
|
2
|
+
// <svelte:head><title> only on mount or when its value changes, so a layout
|
|
3
|
+
// that stays mounted through a navigation never writes it again. A
|
|
4
|
+
// <TitleProbe> after each layout (App.svelte) notes the title written since
|
|
5
|
+
// the probe above it — that layout's own, a <Seo> in it, a dynamic one —
|
|
6
|
+
// so navigation can put it back.
|
|
7
|
+
|
|
8
|
+
/** Bumped once per navigation, so every probe reads again in that flush. */
|
|
9
|
+
export const titleNav = $state({ seq: 0 });
|
|
10
|
+
|
|
11
|
+
/** Per layout depth: the title the layouts down to it show, or null for none. */
|
|
12
|
+
export const chainTitles: (string | null)[] = [];
|
|
13
|
+
|
|
14
|
+
/** Per layout component: the title it wrote last. Kept here, not in the probe,
|
|
15
|
+
* since a layout that wraps its children in {#key} remounts the probe but
|
|
16
|
+
* doesn't write its title again. */
|
|
17
|
+
export const layoutTitles = new Map<unknown, string>();
|
|
18
|
+
|
|
19
|
+
// One record per page, on window, so a second copy of this module (a duplicate
|
|
20
|
+
// chunk, a dev reload) shares the patch instead of finding it taken.
|
|
21
|
+
type TitleWrites = { written: string | null; gen: number; patched: boolean };
|
|
22
|
+
const KEY = "__bosiaTitleWrites";
|
|
23
|
+
const store: TitleWrites =
|
|
24
|
+
typeof window === "undefined"
|
|
25
|
+
? { written: null, gen: 0, patched: false }
|
|
26
|
+
: ((window as any)[KEY] ??= { written: null, gen: 0, patched: false });
|
|
27
|
+
|
|
28
|
+
/** The last title written since the previous call, or null. */
|
|
29
|
+
export function takeTitleWrite(): string | null {
|
|
30
|
+
const w = store.written;
|
|
31
|
+
store.written = null;
|
|
32
|
+
return w;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Every write to document.title, from Svelte or anyone, passes through here.
|
|
36
|
+
// The probes run in tree order, each right after its layout's head effects,
|
|
37
|
+
// so the write each one takes is its layout's. A write is kept only until the
|
|
38
|
+
// next microtask: Svelte flushes in one go, so a probe sees the writes of its
|
|
39
|
+
// own flush, never a page's from an earlier one.
|
|
40
|
+
if (typeof document !== "undefined" && !store.patched) {
|
|
41
|
+
const native = Object.getOwnPropertyDescriptor(Document.prototype, "title");
|
|
42
|
+
if (native?.get && native.set && !Object.prototype.hasOwnProperty.call(document, "title")) {
|
|
43
|
+
const { get, set } = native;
|
|
44
|
+
Object.defineProperty(document, "title", {
|
|
45
|
+
configurable: true,
|
|
46
|
+
get() {
|
|
47
|
+
return get.call(this);
|
|
48
|
+
},
|
|
49
|
+
set(v: string) {
|
|
50
|
+
set.call(this, v);
|
|
51
|
+
store.written = get.call(this);
|
|
52
|
+
const gen = ++store.gen;
|
|
53
|
+
queueMicrotask(() => {
|
|
54
|
+
if (store.gen === gen) store.written = null;
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
store.patched = true;
|
|
59
|
+
} else if (process.env.NODE_ENV !== "production") {
|
|
60
|
+
console.warn(
|
|
61
|
+
"[bosia] document.title is already overridden, so client navigation can't put a layout's title back.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Finding the <title> in a chunk of head markup. Pure string code with no
|
|
2
|
+
// imports, so build.ts and appHtml.ts can use it too — html.ts reads the dist
|
|
3
|
+
// manifest the moment it loads, which neither of them can afford.
|
|
4
|
+
|
|
5
|
+
/** The title a page gets when nothing else sets one. */
|
|
6
|
+
export const DEFAULT_TITLE = "Bosia App";
|
|
7
|
+
|
|
8
|
+
/** Whether a chunk of head markup already sets the document title. Matches a
|
|
9
|
+
* <title> with attributes too. Ignores one in a comment, an attribute value, a
|
|
10
|
+
* script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
|
|
11
|
+
* (which only labels the icon). */
|
|
12
|
+
export function hasTitle(html: string): boolean {
|
|
13
|
+
return titleTagEnd(html) !== -1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The raw text of the <title> that hasTitle finds, or null when there is none.
|
|
17
|
+
* Whitespace is collapsed and trimmed as document.title does; entities are left
|
|
18
|
+
* for the browser to decode. An unclosed <title> runs to the end, as in a browser. */
|
|
19
|
+
export function titleText(html: string): string | null {
|
|
20
|
+
const start = titleTagEnd(html);
|
|
21
|
+
if (start === -1) return null;
|
|
22
|
+
const m = /<\/title(?=[\t\n\f\r />])/i.exec(html.slice(start));
|
|
23
|
+
const raw = m ? html.slice(start, start + m.index) : html.slice(start);
|
|
24
|
+
return collapseTitle(raw);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A title as document.title reads it back: HTML whitespace runs collapsed to
|
|
28
|
+
* one space, and trimmed. */
|
|
29
|
+
export function collapseTitle(text: string): string {
|
|
30
|
+
return text.replace(/[\t\n\f\r ]+/g, " ").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Index just past the `>` of the first real <title> tag, or -1. */
|
|
34
|
+
function titleTagEnd(html: string): number {
|
|
35
|
+
// A single forward scan, not a regex: a lazy `<script…</script>` match retries
|
|
36
|
+
// from every unclosed `<script` and goes quadratic on hostile {@html} output.
|
|
37
|
+
// Each tag is skipped whole, quotes included, so nothing inside an attribute
|
|
38
|
+
// looks like a tag. This is a heuristic, not a full HTML tokenizer.
|
|
39
|
+
let unclosed: Set<string> | undefined;
|
|
40
|
+
let i = 0;
|
|
41
|
+
while ((i = html.indexOf("<", i)) !== -1) {
|
|
42
|
+
if (html.startsWith("<!--", i)) {
|
|
43
|
+
i = commentEnd(html, i);
|
|
44
|
+
if (i === -1) return -1; // unclosed comment runs to the end
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (!isAsciiAlpha(html.charCodeAt(i + 1))) {
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const nameEnd = tagNameEnd(html, i + 1);
|
|
52
|
+
const tagEnd = openTagEnd(html, nameEnd);
|
|
53
|
+
if (tagEnd === -1) return -1; // the rest is inside an unfinished tag
|
|
54
|
+
const name = nameEnd - i - 1 <= MAX_NAME ? html.slice(i + 1, nameEnd).toLowerCase() : "";
|
|
55
|
+
if (name === "title") return tagEnd;
|
|
56
|
+
i = tagEnd;
|
|
57
|
+
const close = SKIPPED_TAGS.get(name);
|
|
58
|
+
// Only foreign content like <svg/> honors the self-closing slash.
|
|
59
|
+
const selfClosed = name === "svg" && html.charCodeAt(tagEnd - 2) === 47;
|
|
60
|
+
if (!close || selfClosed || unclosed?.has(name)) continue;
|
|
61
|
+
// An opener with no closer is read as a plain tag, and remembered, so
|
|
62
|
+
// later openers of that tag don't search to the end again.
|
|
63
|
+
close.lastIndex = tagEnd;
|
|
64
|
+
const gt = close.exec(html) ? html.indexOf(">", close.lastIndex) : -1;
|
|
65
|
+
if (gt === -1) (unclosed ??= new Set()).add(name);
|
|
66
|
+
else i = gt + 1;
|
|
67
|
+
}
|
|
68
|
+
return -1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `</tag` followed by whitespace, `/` or `>`; the caller finds the `>` itself,
|
|
72
|
+
* since a `[^>]*>` here would rescan to the end for every candidate. */
|
|
73
|
+
const SKIPPED_TAGS = new Map(
|
|
74
|
+
["script", "style", "noscript", "template", "svg"].map((t) => [
|
|
75
|
+
t,
|
|
76
|
+
new RegExp(`</${t}(?=[\\t\\n\\f\\r />])`, "gi"),
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** Longest tag name the scan needs to tell apart; longer names are never sliced. */
|
|
81
|
+
const MAX_NAME = Math.max("title".length, ...[...SKIPPED_TAGS.keys()].map((t) => t.length));
|
|
82
|
+
|
|
83
|
+
/** Index just past the comment opening at `start`, or -1 when it never closes.
|
|
84
|
+
* `<!-->` and `<!--->` are complete empty comments. */
|
|
85
|
+
function commentEnd(html: string, start: number): number {
|
|
86
|
+
if (html.startsWith(">", start + 4)) return start + 5;
|
|
87
|
+
if (html.startsWith("->", start + 4)) return start + 6;
|
|
88
|
+
const end = html.indexOf("-->", start + 4);
|
|
89
|
+
return end === -1 ? -1 : end + 3;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** End of the tag name starting at `start`: the first whitespace, `/` or `>`. */
|
|
93
|
+
function tagNameEnd(html: string, start: number): number {
|
|
94
|
+
let j = start;
|
|
95
|
+
while (j < html.length) {
|
|
96
|
+
const c = html.charCodeAt(j);
|
|
97
|
+
if (isHtmlSpace(c) || c === 47 || c === 62) break;
|
|
98
|
+
j++;
|
|
99
|
+
}
|
|
100
|
+
return j;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Index just past the `>` closing a start tag, skipping quoted attribute
|
|
104
|
+
* values, or -1 when the tag never closes. */
|
|
105
|
+
function openTagEnd(html: string, from: number): number {
|
|
106
|
+
for (let j = from; j < html.length; j++) {
|
|
107
|
+
const c = html.charCodeAt(j);
|
|
108
|
+
if (c === 62) return j + 1;
|
|
109
|
+
if (c !== 61) continue; // `=` starts a value
|
|
110
|
+
do j++;
|
|
111
|
+
while (j < html.length && isHtmlSpace(html.charCodeAt(j)));
|
|
112
|
+
const q = html.charCodeAt(j);
|
|
113
|
+
if (q === 34 || q === 39) {
|
|
114
|
+
j = html.indexOf(html[j]!, j + 1);
|
|
115
|
+
if (j === -1) return -1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// Unquoted: runs to whitespace or `>`, quotes and `=` included.
|
|
119
|
+
while (j < html.length && !isHtmlSpace(html.charCodeAt(j)) && html.charCodeAt(j) !== 62) j++;
|
|
120
|
+
j--;
|
|
121
|
+
}
|
|
122
|
+
return -1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** HTML whitespace: tab, LF, FF, CR, space. Not \s, which also matches NBSP. */
|
|
126
|
+
function isHtmlSpace(c: number): boolean {
|
|
127
|
+
return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isAsciiAlpha(c: number): boolean {
|
|
131
|
+
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
132
|
+
}
|