@podosoft/podokit 0.9.0 → 0.10.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.
Files changed (66) hide show
  1. package/dist/add.d.ts +20 -0
  2. package/dist/add.js +121 -8
  3. package/dist/assemble.d.ts +4 -1
  4. package/dist/assemble.js +35 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.js +20 -4
  7. package/dist/inspect.d.ts +5 -0
  8. package/dist/inspect.js +5 -0
  9. package/dist/lockfile.d.ts +20 -1
  10. package/dist/lockfile.js +48 -6
  11. package/dist/remove.js +3 -2
  12. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +42 -0
  13. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/site-runtime.svelte +7 -0
  14. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/server/backend-proxy.ts +3 -1
  15. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+layout.svelte +2 -2
  16. package/dist/templates/fullstack-nest-svelte/dot-gitignore +6 -0
  17. package/dist/templates/fullstack-nest-svelte/infra/k3s/ingress.yaml +10 -0
  18. package/dist/templates/fullstack-nest-svelte/infra/traefik/dynamic.yml +4 -0
  19. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.controller.ts +60 -1
  20. package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +3 -0
  21. package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +3 -1
  22. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +42 -0
  23. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-menu.svelte +46 -0
  24. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +730 -0
  25. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/appearance-card.svelte +371 -0
  26. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +1 -1
  27. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +39 -0
  28. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/index.ts +12 -7
  29. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +32 -0
  30. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +29 -0
  31. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/apply-theme.ts +124 -0
  32. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site/themes.ts +81 -0
  33. package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/site.svelte.ts +6 -0
  34. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/account/+page.svelte +8 -717
  35. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/+page.svelte +17 -9
  36. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(app)/admin/settings/general-settings.svelte +0 -7
  37. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +4 -0
  38. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +35 -0
  39. package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +56 -0
  40. package/dist/templates/modules/admin-dashboard/files/tests/seed.setup.ts +10 -0
  41. package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +9 -2
  42. package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +63 -0
  43. package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +13 -5
  44. package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +21 -2
  45. package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +1 -1
  46. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +5 -3
  47. package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +14 -7
  48. package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +12 -4
  49. package/dist/templates/modules/admin-dashboard/module.manifest.json +1 -1
  50. package/dist/templates/modules/api-key-auth/files/tests/api/machine.api.spec.ts +25 -0
  51. package/dist/templates/modules/bullmq/files/tests/api/jobs.api.spec.ts +38 -0
  52. package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +31 -0
  53. package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +22 -0
  54. package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +28 -0
  55. package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +35 -0
  56. package/dist/templates/modules/redis/files/tests/api/redis.api.spec.ts +30 -0
  57. package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +46 -0
  58. package/dist/templates/todo/apps/web/src/lib/components/site-runtime.svelte +7 -0
  59. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +3 -1
  60. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +2 -2
  61. package/dist/templates/todo/dot-gitignore +6 -0
  62. package/dist/templates/todo/infra/k3s/ingress.yaml +10 -0
  63. package/dist/templates/todo/infra/traefik/dynamic.yml +4 -0
  64. package/dist/update.js +74 -8
  65. package/package.json +1 -1
  66. package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.svelte +0 -61
@@ -0,0 +1,28 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
+ const origin = { origin: base };
5
+
6
+ async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
7
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
8
+ const email = `storage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
9
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "S" } }).catch(() => undefined);
10
+ return ctx;
11
+ }
12
+
13
+ test("object storage: put, get, and presign @smoke", async ({ playwright }) => {
14
+ const ctx = await session(playwright);
15
+ const key = `obj-${Date.now()}`;
16
+ const put = await ctx.put(`/api/storage/${key}`, { data: { content: "hello world" } });
17
+ test.skip(put.status() >= 500, "object storage (MinIO/S3) not reachable");
18
+ expect(put.ok()).toBeTruthy();
19
+ expect(await put.json()).toMatchObject({ key });
20
+
21
+ const got = await (await ctx.get(`/api/storage/${key}`)).json();
22
+ expect(got).toMatchObject({ key, content: "hello world" });
23
+
24
+ const pre = await (await ctx.get(`/api/storage/${key}/presigned`)).json();
25
+ expect(String(pre.url)).toContain(key);
26
+
27
+ await ctx.dispose();
28
+ });
@@ -0,0 +1,35 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
+ const origin = { origin: base };
5
+
6
+ // rate-limit installs a global throttler guard (default RATE_LIMIT_MAX requests
7
+ // per RATE_LIMIT_TTL seconds, per client). This module ships a global guard, so
8
+ // it never joins the shared Outer smoke app (it would throttle every other
9
+ // spec); this spec ships with the module and runs against a dedicated app.
10
+ //
11
+ // Honor RATE_LIMIT_MAX when the test runner has it (a dedicated app sets a small
12
+ // value so the window is crossed in a handful of requests); otherwise fall back
13
+ // to the module default and allow a little headroom.
14
+ const limit = Number(process.env.RATE_LIMIT_MAX ?? 100);
15
+
16
+ test("rate limit: exceeding the window returns 429 @smoke", async ({ playwright }) => {
17
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
18
+ // /health is public — no session needed. If the first hit is a 5xx the
19
+ // throttler's Redis backing isn't reachable; nothing to assert.
20
+ const probe = await ctx.get("/api/health");
21
+ test.skip(probe.status() >= 500, "throttler storage (redis) not reachable");
22
+
23
+ let seen200 = probe.status() === 200;
24
+ let got429 = false;
25
+ for (let i = 0; i < limit + 5 && !got429; i++) {
26
+ const r = await ctx.get("/api/health");
27
+ if (r.status() === 200) seen200 = true;
28
+ if (r.status() === 429) got429 = true;
29
+ }
30
+ // A fresh window serves the first requests (200) and rejects once the limit is
31
+ // crossed (429).
32
+ expect(seen200).toBe(true);
33
+ expect(got429).toBe(true);
34
+ await ctx.dispose();
35
+ });
@@ -0,0 +1,30 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
+ const origin = { origin: base };
5
+
6
+ // Self-contained: sign up + sign in a disposable account so the test works when
7
+ // `auth`'s global guard is installed (the common case). Harmless if the API is open.
8
+ async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
9
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
10
+ const email = `redis-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
11
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "R" } }).catch(() => undefined);
12
+ return ctx;
13
+ }
14
+
15
+ test("redis cache: set, get, and miss @smoke", async ({ playwright }) => {
16
+ const ctx = await session(playwright);
17
+ const key = `k-${Date.now()}`;
18
+ const put = await ctx.put(`/api/cache/${key}`, { data: { value: "hello", ttl: 60 } });
19
+ test.skip(put.status() >= 500, "redis not reachable");
20
+ expect(put.ok()).toBeTruthy();
21
+ expect(await put.json()).toMatchObject({ key });
22
+
23
+ const got = await (await ctx.get(`/api/cache/${key}`)).json();
24
+ expect(got).toMatchObject({ key, value: "hello" });
25
+
26
+ const miss = await (await ctx.get(`/api/cache/missing-${Date.now()}`)).json();
27
+ expect(miss.value).toBeNull();
28
+
29
+ await ctx.dispose();
30
+ });
@@ -0,0 +1,46 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
4
+ const origin = { origin: base };
5
+
6
+ async function session(playwright: import("@playwright/test").PlaywrightWorkerArgs["playwright"]) {
7
+ const ctx = await playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
8
+ const email = `sse-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
9
+ await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "E" } }).catch(() => undefined);
10
+ return ctx;
11
+ }
12
+
13
+ test("sse: publish accepts a message @smoke", async ({ playwright }) => {
14
+ const ctx = await session(playwright);
15
+ const pub = await ctx.post("/api/events", { data: { message: `hi-${Date.now()}` } });
16
+ expect(pub.ok()).toBeTruthy();
17
+ expect(await pub.json()).toMatchObject({ ok: true });
18
+ await ctx.dispose();
19
+ });
20
+
21
+ test("sse: a published message is delivered over the event stream @smoke", async ({ playwright }) => {
22
+ const ctx = await session(playwright);
23
+ // The stream is behind the auth guard — carry the session cookie on the raw fetch.
24
+ const cookies = (await ctx.storageState()).cookies.map((c) => `${c.name}=${c.value}`).join("; ");
25
+ const ac = new AbortController();
26
+ const stream = await fetch(`${base}/api/events/stream`, { headers: { ...origin, cookie: cookies }, signal: ac.signal }).catch(() => null);
27
+ test.skip(!stream || !stream.ok || !stream.body, "sse stream not reachable");
28
+ expect(stream!.headers.get("content-type")).toContain("text/event-stream");
29
+
30
+ // Subscriber is attached now; publish, then read until the message arrives.
31
+ const msg = `sse-${Date.now()}`;
32
+ await ctx.post("/api/events", { data: { message: msg } });
33
+
34
+ const reader = stream!.body!.getReader();
35
+ const decoder = new TextDecoder();
36
+ let seen = false;
37
+ const deadline = Date.now() + 8000;
38
+ while (Date.now() < deadline && !seen) {
39
+ const { value, done } = await reader.read();
40
+ if (done) break;
41
+ if (decoder.decode(value).includes(msg)) seen = true;
42
+ }
43
+ ac.abort();
44
+ expect(seen).toBe(true);
45
+ await ctx.dispose();
46
+ });
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ // Modules can replace this managed component with global site behavior while
3
+ // the surrounding route layout remains application-owned.
4
+ import { ModeWatcher } from "mode-watcher";
5
+ </script>
6
+
7
+ <ModeWatcher />
@@ -37,7 +37,9 @@ export async function proxyRequest(
37
37
  const upstream = await fetch(targetUrl, {
38
38
  method: request.method,
39
39
  headers,
40
- body: hasBody ? await request.text() : undefined,
40
+ // Preserve multipart uploads and other binary bodies byte-for-byte. Reading
41
+ // them as text corrupts bytes that are not valid UTF-8 before forwarding.
42
+ body: hasBody ? await request.arrayBuffer() : undefined,
41
43
  redirect: "manual",
42
44
  });
43
45
 
@@ -1,9 +1,9 @@
1
1
  <script lang="ts">
2
2
  import "../app.css";
3
- import { ModeWatcher } from "mode-watcher";
3
+ import SiteRuntime from "$lib/components/site-runtime.svelte";
4
4
 
5
5
  let { children } = $props();
6
6
  </script>
7
7
 
8
- <ModeWatcher />
8
+ <SiteRuntime />
9
9
  {@render children()}
@@ -12,3 +12,9 @@ build/
12
12
  tests/playwright/
13
13
  tests/playwright-report/
14
14
  tests/test-results/
15
+ playwright/.auth/
16
+ playwright-report/
17
+ test-results/
18
+
19
+ # local performance reports
20
+ output/lighthouse/
@@ -1,8 +1,18 @@
1
+ apiVersion: traefik.io/v1alpha1
2
+ kind: Middleware
3
+ metadata:
4
+ name: compression
5
+ namespace: podokit
6
+ spec:
7
+ compress: {}
8
+ ---
1
9
  apiVersion: networking.k8s.io/v1
2
10
  kind: Ingress
3
11
  metadata:
4
12
  name: app
5
13
  namespace: podokit
14
+ annotations:
15
+ traefik.ingress.kubernetes.io/router.middlewares: podokit-compression@kubernetescrd
6
16
  spec:
7
17
  rules:
8
18
  - host: example.com
@@ -5,7 +5,11 @@ http:
5
5
  web:
6
6
  rule: "PathPrefix(`/`)" # any host on this dedicated proxy -> web
7
7
  entryPoints: [web]
8
+ middlewares: [compression]
8
9
  service: web
10
+ middlewares:
11
+ compression:
12
+ compress: {}
9
13
  services:
10
14
  web:
11
15
  loadBalancer:
package/dist/update.js CHANGED
@@ -9,6 +9,7 @@ const podokit_template_engine_1 = require("@podosoft/podokit-template-engine");
9
9
  const assemble_1 = require("./assemble");
10
10
  const lockfile_1 = require("./lockfile");
11
11
  const inspect_1 = require("./inspect");
12
+ const add_1 = require("./add");
12
13
  function treeText(tree, path) {
13
14
  const file = tree.get(path);
14
15
  if (!file)
@@ -32,7 +33,8 @@ function planUpdate(projectRoot, templatesDir) {
32
33
  templatesDir,
33
34
  template: manifest.template,
34
35
  answers: manifest.answers,
35
- modules: manifest.modules.map((m) => m.name),
36
+ modules: manifest.modules,
37
+ projectRoot,
36
38
  });
37
39
  const changes = [];
38
40
  const paths = new Set([...newTree.keys(), ...Object.keys(lock.files)]);
@@ -92,6 +94,40 @@ function writeFile(projectRoot, path, content) {
92
94
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(abs), { recursive: true });
93
95
  (0, node_fs_1.writeFileSync)(abs, content);
94
96
  }
97
+ /**
98
+ * Refresh update metadata without teaching the lock that a clean 3-way merge is
99
+ * PodoKit's new baseline. Managed files keep the newly assembled template hash,
100
+ * so the user's merged lines are still recognised as edits on the next update.
101
+ * Files that are neither in the assembled tree nor explicitly owned stay out of
102
+ * the lock instead of being accidentally adopted as managed files.
103
+ */
104
+ function updatedFilesLock(projectRoot, newTree, previous, ownedGlobs) {
105
+ const next = (0, lockfile_1.computeFilesLock)(projectRoot, ownedGlobs);
106
+ for (const [path, entry] of Object.entries(next.files)) {
107
+ const newText = treeText(newTree, path);
108
+ if (newText !== null) {
109
+ if (entry.tier !== "owned") {
110
+ entry.tier = previous.files[path]?.tier ?? entry.tier;
111
+ entry.outHash = (0, podokit_template_engine_1.hashContent)(newText);
112
+ }
113
+ continue;
114
+ }
115
+ if (entry.tier === "owned")
116
+ continue;
117
+ const oldEntry = previous.files[path];
118
+ if (oldEntry && oldEntry.tier !== "owned") {
119
+ // Removed upstream but kept on disk because it was edited: continue to
120
+ // report the removal conflict until the user deletes or ejects it.
121
+ entry.tier = oldEntry.tier;
122
+ entry.outHash = oldEntry.outHash;
123
+ }
124
+ else {
125
+ // A file created by the application is not implicitly PodoKit-managed.
126
+ delete next.files[path];
127
+ }
128
+ }
129
+ return next;
130
+ }
95
131
  /**
96
132
  * Apply an update to the working copy. Clean updates and additions are written,
97
133
  * upstream removals deleted, and user-edited files 3-way merged against the old
@@ -101,13 +137,26 @@ function writeFile(projectRoot, path, content) {
101
137
  */
102
138
  function applyUpdate(projectRoot, templatesDir, options = {}) {
103
139
  const manifest = (0, lockfile_1.readManifest)(projectRoot);
104
- if (!manifest)
140
+ const previousLock = (0, lockfile_1.readFilesLock)(projectRoot);
141
+ if (!manifest || !previousLock)
105
142
  throw new inspect_1.NotAProjectError();
106
143
  const plan = planUpdate(projectRoot, templatesDir);
107
- const modules = manifest.modules.map((m) => m.name);
108
- const newTree = (0, assemble_1.assembleProject)({ templatesDir, template: manifest.template, answers: manifest.answers, modules });
144
+ const modules = manifest.modules;
145
+ const newTree = (0, assemble_1.assembleProject)({
146
+ templatesDir,
147
+ template: manifest.template,
148
+ answers: manifest.answers,
149
+ modules,
150
+ projectRoot,
151
+ });
109
152
  const oldTree = options.oldTemplatesDir
110
- ? (0, assemble_1.assembleProject)({ templatesDir: options.oldTemplatesDir, template: manifest.template, answers: manifest.answers, modules })
153
+ ? (0, assemble_1.assembleProject)({
154
+ templatesDir: options.oldTemplatesDir,
155
+ template: manifest.template,
156
+ answers: manifest.answers,
157
+ modules,
158
+ projectRoot,
159
+ })
111
160
  : null;
112
161
  const result = { written: [], removed: [], merged: [], conflicts: [] };
113
162
  for (const change of plan.changes) {
@@ -136,9 +185,26 @@ function applyUpdate(projectRoot, templatesDir, options = {}) {
136
185
  result.merged.push(change.path);
137
186
  }
138
187
  }
139
- // Refresh the lock to match what we just wrote and stamp the new version.
140
- (0, lockfile_1.writeFilesLock)(projectRoot, (0, lockfile_1.computeFilesLock)(projectRoot, manifest.ownedGlobs));
141
- (0, lockfile_1.writeManifest)(projectRoot, { ...manifest, podokitVersion: (0, lockfile_1.podokitVersion)() });
188
+ // Keep the assembled target as the managed baseline. A merged working file
189
+ // intentionally remains drifted so a future update performs another 3-way
190
+ // merge instead of treating the user's lines as clean generated output.
191
+ (0, lockfile_1.writeFilesLock)(projectRoot, updatedFilesLock(projectRoot, newTree, previousLock, manifest.ownedGlobs));
192
+ const modulesDir = (0, node_path_1.join)(templatesDir, "modules");
193
+ const refreshedModules = manifest.modules.map((module) => {
194
+ const resolved = (0, add_1.resolveModule)(module.packageName ?? module.name, modulesDir, projectRoot);
195
+ return resolved?.packageName
196
+ ? {
197
+ ...module,
198
+ packageName: resolved.packageName,
199
+ moduleVersion: resolved.moduleVersion,
200
+ }
201
+ : module;
202
+ });
203
+ (0, lockfile_1.writeManifest)(projectRoot, {
204
+ ...manifest,
205
+ modules: refreshedModules,
206
+ podokitVersion: (0, lockfile_1.podokitVersion)(),
207
+ });
142
208
  return result;
143
209
  }
144
210
  /** Count actions for a one-line summary. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podosoft/podokit",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "An opinionated but extensible starter toolkit and CLI for full-stack TypeScript apps with NestJS, SvelteKit, TailwindCSS, shadcn-svelte, Docker, and k3s.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,61 +0,0 @@
1
- <script lang="ts">
2
- import "../app.css";
3
- import { ModeWatcher } from "mode-watcher";
4
- import { setI18nContext } from "$lib/i18n";
5
- import { messages, resolveLocale } from "$lib/i18n/messages";
6
- import { site } from "$lib/site.svelte";
7
-
8
- let { children, data } = $props();
9
- const locale = $derived(resolveLocale(data.locale));
10
- setI18nContext({
11
- get t() {
12
- return messages[locale];
13
- },
14
- get locale() {
15
- return locale;
16
- },
17
- });
18
-
19
- // Seed the reactive site branding; the general-settings form patches it live.
20
- $effect.pre(() => site.init(data.site));
21
- // Prefer live store values (patched after a save); fall back to the server load
22
- // so SSR renders the saved branding too. Empty falls back to the app.html default.
23
- const branding = $derived({
24
- name: site.value.name ?? data.site?.name ?? "",
25
- description: site.value.description ?? data.site?.description ?? "",
26
- brandColor: site.value.brandColor ?? data.site?.brandColor ?? "",
27
- hasFavicon: site.value.hasFavicon || Boolean(data.site?.hasFavicon),
28
- faviconVersion: site.value.faviconVersion ?? data.site?.faviconVersion ?? "",
29
- });
30
- // Apply the site name to the browser tab live (avoids a duplicate <title> vs
31
- // the app.html default; the default project name stays as the SSR fallback).
32
- $effect(() => {
33
- if (branding.name) document.title = branding.name;
34
- });
35
- // Apply the brand color as the theme's primary accent live. Clearing it in
36
- // Settings restores the stylesheet default (remove the inline override).
37
- $effect(() => {
38
- const root = document.documentElement;
39
- if (branding.brandColor) {
40
- root.style.setProperty("--primary", branding.brandColor);
41
- root.style.setProperty("--sidebar-primary", branding.brandColor);
42
- root.style.setProperty("--ring", branding.brandColor);
43
- } else {
44
- root.style.removeProperty("--primary");
45
- root.style.removeProperty("--sidebar-primary");
46
- root.style.removeProperty("--ring");
47
- }
48
- });
49
- </script>
50
-
51
- <svelte:head>
52
- {#if branding.description}
53
- <meta name="description" content={branding.description} />
54
- {/if}
55
- {#if branding.hasFavicon}
56
- <link rel="icon" href={`/api/site/favicon?v=${branding.faviconVersion}`} />
57
- {/if}
58
- </svelte:head>
59
-
60
- <ModeWatcher />
61
- {@render children()}