@velastack/cms 0.1.0 → 0.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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/dist/backend/auth/editors.d.ts +51 -0
  3. package/dist/backend/auth/editors.js +157 -0
  4. package/dist/backend/auth/local-editors.d.ts +22 -0
  5. package/dist/backend/auth/local-editors.js +44 -0
  6. package/dist/backend/auth/scrypt.d.ts +20 -0
  7. package/dist/backend/auth/scrypt.js +80 -0
  8. package/dist/backend/factory.d.ts +25 -0
  9. package/dist/backend/factory.js +202 -0
  10. package/dist/backend/http.d.ts +34 -0
  11. package/dist/backend/http.js +79 -0
  12. package/dist/backend/index.d.ts +45 -0
  13. package/dist/backend/index.js +39 -0
  14. package/dist/backend/router.d.ts +52 -0
  15. package/dist/backend/router.js +57 -0
  16. package/dist/backend/routes/context.d.ts +46 -0
  17. package/dist/backend/routes/context.js +30 -0
  18. package/dist/backend/routes/docs.d.ts +2 -0
  19. package/dist/backend/routes/docs.js +150 -0
  20. package/dist/backend/routes/login.d.ts +19 -0
  21. package/dist/backend/routes/login.js +155 -0
  22. package/dist/backend/routes/media.d.ts +22 -0
  23. package/dist/backend/routes/media.js +119 -0
  24. package/dist/backend/routes/pages.d.ts +19 -0
  25. package/dist/backend/routes/pages.js +221 -0
  26. package/dist/backend/routes/release.d.ts +10 -0
  27. package/dist/backend/routes/release.js +147 -0
  28. package/dist/backend/routes/site.d.ts +2 -0
  29. package/dist/backend/routes/site.js +40 -0
  30. package/dist/backend/routes/table.d.ts +11 -0
  31. package/dist/backend/routes/table.js +72 -0
  32. package/dist/backend/routes/test-reset.d.ts +15 -0
  33. package/dist/backend/routes/test-reset.js +112 -0
  34. package/dist/backend/routes/user.d.ts +14 -0
  35. package/dist/backend/routes/user.js +21 -0
  36. package/dist/backend/storage.d.ts +18 -0
  37. package/dist/backend/storage.js +45 -0
  38. package/dist/backend/store/cache.d.ts +34 -0
  39. package/dist/backend/store/cache.js +38 -0
  40. package/dist/backend/store/db.d.ts +10 -0
  41. package/dist/backend/store/db.js +29 -0
  42. package/dist/backend/store/migrate.d.ts +10 -0
  43. package/dist/backend/store/migrate.js +30 -0
  44. package/dist/backend/store/migrations/0001-init.d.ts +1 -0
  45. package/dist/backend/store/migrations/0001-init.js +129 -0
  46. package/dist/backend/store/migrations/0002-site.d.ts +1 -0
  47. package/dist/backend/store/migrations/0002-site.js +86 -0
  48. package/dist/backend/store/migrations/0003-editors.d.ts +1 -0
  49. package/dist/backend/store/migrations/0003-editors.js +44 -0
  50. package/dist/backend/store/migrations/index.d.ts +15 -0
  51. package/dist/backend/store/migrations/index.js +18 -0
  52. package/dist/backend/store/queries.d.ts +67 -0
  53. package/dist/backend/store/queries.js +1207 -0
  54. package/dist/backend/store/sqlite.d.ts +22 -0
  55. package/dist/backend/store/sqlite.js +29 -0
  56. package/dist/backend/testing/fixtures.d.ts +39 -0
  57. package/dist/backend/testing/fixtures.js +61 -0
  58. package/dist/backend/testing/harness.d.ts +35 -0
  59. package/dist/backend/testing/harness.js +149 -0
  60. package/dist/backend/types.d.ts +102 -0
  61. package/dist/cms.d.ts +3 -3
  62. package/dist/cms.js +1 -1
  63. package/dist/components/admin-bar/publish-dialog.svelte +1 -1
  64. package/dist/components/cms/cms-image-editable.svelte +1 -1
  65. package/dist/components/cms/cms-store.svelte.d.ts +3 -68
  66. package/dist/components/cms/cms-store.svelte.js +25 -10
  67. package/dist/components/cms/locale-merge.d.ts +1 -1
  68. package/dist/components/cms/locale-merge.js +1 -1
  69. package/dist/components/cms/scope.d.ts +1 -1
  70. package/dist/core/page-entry.d.ts +43 -0
  71. package/dist/core/page-entry.js +21 -0
  72. package/dist/{components/cms → core}/path.d.ts +13 -0
  73. package/dist/{components/cms → core}/path.js +36 -0
  74. package/dist/core/wire.d.ts +182 -0
  75. package/dist/core/wire.js +1 -0
  76. package/dist/{srv → server}/api-adapter.js +2 -2
  77. package/dist/{srv → server}/load-cms.js +1 -1
  78. package/dist/{srv → server}/mock-adapter.d.ts +3 -24
  79. package/dist/{srv → server}/mock-adapter.js +5 -22
  80. package/dist/{srv → server}/types.d.ts +2 -6
  81. package/dist/server/types.js +1 -0
  82. package/package.json +13 -4
  83. package/plugin/dist/index.js +7 -0
  84. package/plugin/dist/index.js.map +1 -1
  85. package/plugin/dist/media.d.ts +16 -7
  86. package/plugin/dist/media.js +23 -10
  87. package/plugin/dist/media.js.map +1 -1
  88. /package/dist/{srv → backend}/types.js +0 -0
  89. /package/dist/{srv → server}/README.md +0 -0
  90. /package/dist/{srv → server}/api-adapter.d.ts +0 -0
  91. /package/dist/{srv → server}/cms.d.ts +0 -0
  92. /package/dist/{srv → server}/cms.js +0 -0
  93. /package/dist/{srv → server}/index.d.ts +0 -0
  94. /package/dist/{srv → server}/index.js +0 -0
  95. /package/dist/{srv → server}/load-cms.d.ts +0 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Cache-Control for public CMS reads. Short browser TTL keeps editors close
3
+ * to fresh after publish; `stale-while-revalidate` smooths cache misses; the
4
+ * larger `s-maxage` lets a CDN hold the cached copy longer than browsers do.
5
+ */
6
+ const PUBLIC_CC = 'public, max-age=10, stale-while-revalidate=300, s-maxage=300';
7
+ /** `?version=<key>` reads are bound to a published release id and never
8
+ * change — safe to cache forever. */
9
+ const IMMUTABLE_CC = 'public, max-age=31536000, immutable';
10
+ const JSON_TYPE = 'application/json; charset=utf-8';
11
+ /**
12
+ * Bind the cache-aware responders to one store and one read cache.
13
+ *
14
+ * The bodies are a verbatim move: the header values, the ETag basis, the
15
+ * positive-only caching, and the branch order are the contract the shipped
16
+ * client already depends on, so none of it is re-derived here.
17
+ *
18
+ * Note these take a duck-typed `{ request, setHeaders }` rather than a full
19
+ * `RequestEvent`, which is what lets the tests drive them directly.
20
+ */
21
+ export const createResponders = (store, cache) => {
22
+ /**
23
+ * Check the LRU and `If-None-Match` for a public read. On hit/304 returns a
24
+ * ready Response. On miss returns `{ hit: false, version, etag }` so the
25
+ * caller can pass them back into `servePublicJson` / `servePublicNotFound`
26
+ * without re-reading `cms_version` (avoiding a publish-races-read TOCTOU).
27
+ */
28
+ const tryPublicCache = (event, projectId, scope) => {
29
+ const version = store.getCmsVersion(projectId);
30
+ const etag = `"${version}"`;
31
+ const ifNoneMatch = event.request.headers.get('if-none-match');
32
+ if (ifNoneMatch === etag) {
33
+ event.setHeaders({ 'cache-control': PUBLIC_CC, etag });
34
+ return { hit: true, response: new Response(null, { status: 304 }) };
35
+ }
36
+ const cached = cache.get({ projectId, cmsVersion: version, scope });
37
+ if (cached !== undefined) {
38
+ event.setHeaders({ 'cache-control': PUBLIC_CC, etag, 'content-type': JSON_TYPE });
39
+ return { hit: true, response: new Response(cached, { status: 200 }) };
40
+ }
41
+ return { hit: false, version, etag };
42
+ };
43
+ /** Serve and LRU-cache a 200 JSON response for a public read. */
44
+ const servePublicJson = (event, projectId, scope, pass, payload) => {
45
+ const body = JSON.stringify(payload);
46
+ cache.set({ projectId, cmsVersion: pass.version, scope }, body);
47
+ event.setHeaders({
48
+ 'cache-control': PUBLIC_CC,
49
+ etag: pass.etag,
50
+ 'content-type': JSON_TYPE
51
+ });
52
+ return new Response(body, { status: 200 });
53
+ };
54
+ /**
55
+ * Serve a 404 for a public read. We set Cache-Control + ETag so the CDN /
56
+ * browser can negative-cache briefly, but do NOT populate the LRU — the cache
57
+ * is positive-only so empty-tenant flooding can't poison it.
58
+ */
59
+ const servePublicNotFound = (event, pass) => {
60
+ event.setHeaders({ 'cache-control': PUBLIC_CC, etag: pass.etag });
61
+ return new Response(null, { status: 404 });
62
+ };
63
+ /** Serve a JSON 200 for `?version=<key>` — immutable per release. */
64
+ const serveImmutableJson = (event, payload) => {
65
+ event.setHeaders({ 'cache-control': IMMUTABLE_CC, 'content-type': JSON_TYPE });
66
+ return new Response(JSON.stringify(payload), { status: 200 });
67
+ };
68
+ const serveImmutableNotFound = (event) => {
69
+ event.setHeaders({ 'cache-control': IMMUTABLE_CC });
70
+ return new Response(null, { status: 404 });
71
+ };
72
+ return {
73
+ tryPublicCache,
74
+ servePublicJson,
75
+ servePublicNotFound,
76
+ serveImmutableJson,
77
+ serveImmutableNotFound
78
+ };
79
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `@velastack/cms/backend` — the CMS HTTP API, mountable at any path.
3
+ *
4
+ * ```ts
5
+ * // src/lib/server/cms.ts
6
+ * import { createCmsBackend } from '@velastack/cms/backend';
7
+ * export const cms = createCmsBackend();
8
+ *
9
+ * // src/routes/cms/[...path]/+server.ts
10
+ * import { cms } from '../server/cms';
11
+ * export const prerender = false;
12
+ * export const fallback = cms.handler;
13
+ * ```
14
+ *
15
+ * Multi-tenant is the same file with a `resolveProject`:
16
+ *
17
+ * ```ts
18
+ * createCmsBackend({ resolveProject: (event) => event.params.project_id ?? null });
19
+ * ```
20
+ *
21
+ * Requires `better-sqlite3`, declared as an optional peer dependency — projects
22
+ * that only use the components and the SSR read path never install it.
23
+ *
24
+ * Two things a cross-origin deployment must get right. `cors` is off by
25
+ * default; enabling it with an `origin` predicate that returns `true`
26
+ * unconditionally lets any site drive the mutating endpoints with a signed-in
27
+ * editor's cookie, because SvelteKit's CSRF check only guards form content
28
+ * types and these are JSON. And SvelteKit *does* guard `multipart/form-data`,
29
+ * so a cross-origin `POST /media` needs the site's origin in
30
+ * `kit.csrf.trustedOrigins` — that check is production-only, so it will not
31
+ * show up in `vela dev`.
32
+ */
33
+ export { createCmsBackend, type CmsBackend } from './factory.js';
34
+ export { localEditors, type LocalEditorsOptions } from './auth/local-editors.js';
35
+ export { createEditorStore, type CmsEditorStore, type CreateEditorInput, type CmsSessionToken } from './auth/editors.js';
36
+ export { hashPassword, verifyPassword, type ScryptParams } from './auth/scrypt.js';
37
+ export { createCmsTestClient, type CmsTestClient, type TestResponse } from './testing/harness.js';
38
+ export { createTestFixture, TEST_SCRYPT, type TestFixture, type TestFixtureOptions } from './testing/fixtures.js';
39
+ export type { CmsAuthAdapter, CmsAuthContext, CmsBackendOptions, CmsCookieOptions, CmsCorsOptions, CmsEditor, CmsSessionGrant } from './types.js';
40
+ export type { SqliteDb, SqliteStatement } from './store/sqlite.js';
41
+ export type { Migration } from './store/migrations/index.js';
42
+ export { MIGRATIONS } from './store/migrations/index.js';
43
+ export type { DocsResponse, MediaItem, OpenRelease, PageMapEntry, PageMapRoute, PagesResponse, PagesRoute, PublishedRelease, ReleaseItem, SiteResponse } from '../core/wire.js';
44
+ export type { PageDeleteOutcome, PageEntry } from '../core/page-entry.js';
45
+ export type { Tree } from '../core/path.js';
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `@velastack/cms/backend` — the CMS HTTP API, mountable at any path.
3
+ *
4
+ * ```ts
5
+ * // src/lib/server/cms.ts
6
+ * import { createCmsBackend } from '@velastack/cms/backend';
7
+ * export const cms = createCmsBackend();
8
+ *
9
+ * // src/routes/cms/[...path]/+server.ts
10
+ * import { cms } from '../server/cms';
11
+ * export const prerender = false;
12
+ * export const fallback = cms.handler;
13
+ * ```
14
+ *
15
+ * Multi-tenant is the same file with a `resolveProject`:
16
+ *
17
+ * ```ts
18
+ * createCmsBackend({ resolveProject: (event) => event.params.project_id ?? null });
19
+ * ```
20
+ *
21
+ * Requires `better-sqlite3`, declared as an optional peer dependency — projects
22
+ * that only use the components and the SSR read path never install it.
23
+ *
24
+ * Two things a cross-origin deployment must get right. `cors` is off by
25
+ * default; enabling it with an `origin` predicate that returns `true`
26
+ * unconditionally lets any site drive the mutating endpoints with a signed-in
27
+ * editor's cookie, because SvelteKit's CSRF check only guards form content
28
+ * types and these are JSON. And SvelteKit *does* guard `multipart/form-data`,
29
+ * so a cross-origin `POST /media` needs the site's origin in
30
+ * `kit.csrf.trustedOrigins` — that check is production-only, so it will not
31
+ * show up in `vela dev`.
32
+ */
33
+ export { createCmsBackend } from './factory.js';
34
+ export { localEditors } from './auth/local-editors.js';
35
+ export { createEditorStore } from './auth/editors.js';
36
+ export { hashPassword, verifyPassword } from './auth/scrypt.js';
37
+ export { createCmsTestClient } from './testing/harness.js';
38
+ export { createTestFixture, TEST_SCRYPT } from './testing/fixtures.js';
39
+ export { MIGRATIONS } from './store/migrations/index.js';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Path dispatch for the catch-all mount.
3
+ *
4
+ * Every endpoint arrives at one `+server.ts` as a rest parameter, so the route
5
+ * table lives here rather than in the filesystem. That is the whole point of
6
+ * the design: adding or changing an endpoint is a package release, and the
7
+ * application's mount file never changes.
8
+ */
9
+ import type { RequestEvent } from '@sveltejs/kit';
10
+ import type { RouteCtx, RouteHandler } from './routes/context.js';
11
+ /**
12
+ * `public` — anonymous callers are served (published content only).
13
+ * `required` — no editor is a 403.
14
+ * `exempt` — skips the cross-project check, so an editor holding a session
15
+ * for one project can still reach another's login form and log
16
+ * out. Reproduces `isAuthEndpoint` from the hook this replaces.
17
+ */
18
+ export type RouteAuth = 'public' | 'required' | 'exempt';
19
+ export type Route = {
20
+ method: 'GET' | 'POST' | 'DELETE';
21
+ /** Path segments; a leading `:` captures. */
22
+ segments: string[];
23
+ auth: RouteAuth;
24
+ handler: RouteHandler;
25
+ };
26
+ export type RouteMatch = {
27
+ route: Route;
28
+ params: Record<string, string>;
29
+ };
30
+ export type RouteLookup = {
31
+ kind: 'match';
32
+ match: RouteMatch;
33
+ } | {
34
+ kind: 'method-not-allowed';
35
+ allowed: string[];
36
+ } | {
37
+ kind: 'not-found';
38
+ };
39
+ /** Split a rest-parameter value into path segments. */
40
+ export declare const splitPath: (path: string) => string[];
41
+ /**
42
+ * Resolve a method + path against the table.
43
+ *
44
+ * A path that matches some route but not this method is a 405 with `Allow`,
45
+ * not a 404 — the distinction matters for anyone debugging a mount.
46
+ */
47
+ export declare const lookupRoute: (routes: Route[], method: string, segments: string[]) => RouteLookup;
48
+ export declare const methodNotAllowed: (allowed: string[]) => Response;
49
+ export declare const notFound: () => Response;
50
+ export type { RouteCtx, RouteHandler };
51
+ /** Narrow a raw method string, mapping HEAD onto GET as SvelteKit does. */
52
+ export declare const normalizeMethod: (event: RequestEvent) => string;
@@ -0,0 +1,57 @@
1
+ const segmentsMatch = (pattern, actual) => {
2
+ if (pattern.length !== actual.length)
3
+ return null;
4
+ const params = {};
5
+ for (let i = 0; i < pattern.length; i++) {
6
+ const p = pattern[i];
7
+ if (p.startsWith(':')) {
8
+ if (actual[i] === '')
9
+ return null;
10
+ params[p.slice(1)] = decodeURIComponent(actual[i]);
11
+ }
12
+ else if (p !== actual[i]) {
13
+ return null;
14
+ }
15
+ }
16
+ return params;
17
+ };
18
+ /** Split a rest-parameter value into path segments. */
19
+ export const splitPath = (path) => path.split('/').filter(Boolean);
20
+ /**
21
+ * Resolve a method + path against the table.
22
+ *
23
+ * A path that matches some route but not this method is a 405 with `Allow`,
24
+ * not a 404 — the distinction matters for anyone debugging a mount.
25
+ */
26
+ export const lookupRoute = (routes, method, segments) => {
27
+ const byPath = [];
28
+ for (const route of routes) {
29
+ const params = segmentsMatch(route.segments, segments);
30
+ if (params)
31
+ byPath.push({ route, params });
32
+ }
33
+ if (byPath.length === 0)
34
+ return { kind: 'not-found' };
35
+ // A literal segment beats a capture, so `/media` never shadows `/media/:id`
36
+ // and vice versa.
37
+ const specificity = (r) => r.segments.filter((s) => !s.startsWith(':')).length;
38
+ byPath.sort((a, b) => specificity(b.route) - specificity(a.route));
39
+ const hit = byPath.find((c) => c.route.method === method);
40
+ if (hit)
41
+ return { kind: 'match', match: { route: hit.route, params: hit.params } };
42
+ const methods = [...new Set(byPath.map((c) => c.route.method))];
43
+ if (methods.includes('GET'))
44
+ methods.push('HEAD');
45
+ methods.push('OPTIONS');
46
+ return { kind: 'method-not-allowed', allowed: methods };
47
+ };
48
+ export const methodNotAllowed = (allowed) => new Response(`Method not allowed. Allowed: ${allowed.join(', ')}`, {
49
+ status: 405,
50
+ headers: { allow: allowed.join(', '), 'content-type': 'text/plain; charset=utf-8' }
51
+ });
52
+ export const notFound = () => new Response('Not found', {
53
+ status: 404,
54
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
55
+ });
56
+ /** Narrow a raw method string, mapping HEAD onto GET as SvelteKit does. */
57
+ export const normalizeMethod = (event) => event.request.method === 'HEAD' ? 'GET' : event.request.method;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What every route handler receives.
3
+ *
4
+ * Handlers used to read `event.locals.cmsUser` and `event.params.project_id`,
5
+ * both of which are conventions of one particular application. Passing them
6
+ * explicitly is what makes the same handler work at `/cms` and at
7
+ * `/v1/projects/[project_id]/cms` without knowing which it is.
8
+ */
9
+ import type { RequestEvent } from '@sveltejs/kit';
10
+ import type { CmsStore } from '../store/queries.js';
11
+ import type { ReadCache } from '../store/cache.js';
12
+ import type { CmsResponders } from '../http.js';
13
+ import type { CmsStorage } from '../storage.js';
14
+ import type { CmsAuthAdapter, CmsAuthContext, CmsEditor } from '../types.js';
15
+ export type RouteCtx = {
16
+ event: RequestEvent;
17
+ /** The tenant this request is for. `'default'` in a single-tenant mount. */
18
+ projectId: string;
19
+ /** The signed-in editor, or `null`. Routes declared `required` never see null. */
20
+ user: CmsEditor | null;
21
+ /** Captured `:name` segments from the route pattern. */
22
+ params: Record<string, string>;
23
+ store: CmsStore;
24
+ cache: ReadCache;
25
+ respond: CmsResponders;
26
+ storage: CmsStorage;
27
+ auth: CmsAuthAdapter;
28
+ authCtx: CmsAuthContext;
29
+ /** Absolute-or-root-relative URL for an uploaded file, per the host's
30
+ * configured media base. */
31
+ mediaUrl: (filename: string) => string;
32
+ /** The path the backend is mounted at, without a trailing slash. */
33
+ mountPath: string;
34
+ frameAncestors: string;
35
+ setSessionCookie: (token: string, expiresAt: Date) => void;
36
+ clearSessionCookie: () => void;
37
+ };
38
+ export type RouteHandler = (ctx: RouteCtx) => Response | Promise<Response>;
39
+ /** The signed-in editor, for routes the table declares `required`. */
40
+ export declare const requireUser: (ctx: RouteCtx) => CmsEditor;
41
+ export declare const badRequest: (message: string) => Response;
42
+ export declare const forbidden: () => Response;
43
+ export declare const isObjectRecord: (v: unknown) => v is Record<string, unknown>;
44
+ export declare const isStringRecord: (v: unknown) => v is Record<string, string>;
45
+ /** Parse a JSON body, or `undefined` when it is absent or malformed. */
46
+ export declare const readJson: (event: RequestEvent) => Promise<unknown | undefined>;
@@ -0,0 +1,30 @@
1
+ /** The signed-in editor, for routes the table declares `required`. */
2
+ export const requireUser = (ctx) => {
3
+ if (!ctx.user)
4
+ throw new Error('route requires a user but the router allowed an anonymous call');
5
+ return ctx.user;
6
+ };
7
+ export const badRequest = (message) => new Response(message, { status: 400, headers: { 'content-type': 'text/plain; charset=utf-8' } });
8
+ export const forbidden = () => new Response(null, { status: 403 });
9
+ export const isObjectRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
10
+ export const isStringRecord = (v) => {
11
+ if (!isObjectRecord(v))
12
+ return false;
13
+ for (const val of Object.values(v)) {
14
+ if (typeof val !== 'string')
15
+ return false;
16
+ }
17
+ return true;
18
+ };
19
+ /** Parse a JSON body, or `undefined` when it is absent or malformed. */
20
+ export const readJson = async (event) => {
21
+ const text = await event.request.text();
22
+ if (!text)
23
+ return undefined;
24
+ try {
25
+ return JSON.parse(text);
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ };
@@ -0,0 +1,2 @@
1
+ import { type RouteCtx } from './context.js';
2
+ export declare const getDocs: (ctx: RouteCtx) => Response;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `GET /docs` — the hot read path.
3
+ *
4
+ * Ported from the original route handler with its three modes and their cache
5
+ * semantics unchanged: `?version=` reads an immutable release snapshot,
6
+ * `?preview=` overlays the caller's open release and bypasses every cache
7
+ * layer, and a plain read goes through the per-project LRU with an ETag keyed
8
+ * on `cms_version`. `?version` wins when both are present, by branch order.
9
+ */
10
+ import { mergeTree } from '../../core/path.js';
11
+ import { findPageEntry } from '../../core/page-entry.js';
12
+ import { DEFAULT_LOCALE } from '../store/queries.js';
13
+ import { badRequest } from './context.js';
14
+ const parseParams = (raw) => {
15
+ if (raw == null || raw === '')
16
+ return {};
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
20
+ return null;
21
+ const out = {};
22
+ for (const [k, v] of Object.entries(parsed)) {
23
+ if (typeof v !== 'string')
24
+ return null;
25
+ out[k] = v;
26
+ }
27
+ return out;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ };
33
+ const paramsEqual = (a, b) => {
34
+ const aKeys = Object.keys(a);
35
+ if (aKeys.length !== Object.keys(b).length)
36
+ return false;
37
+ for (const k of aKeys)
38
+ if (a[k] !== b[k])
39
+ return false;
40
+ return true;
41
+ };
42
+ /** Stable cache scope key for one /docs request. Mirrors the params hash shape
43
+ * from the store so the LRU partitions cleanly per scope. */
44
+ const cacheScope = (kind, locale, routeId, params) => {
45
+ if (kind === 'layout')
46
+ return `docs:layout:${locale}:${routeId}`;
47
+ const keys = Object.keys(params).sort();
48
+ const sorted = {};
49
+ for (const k of keys)
50
+ sorted[k] = params[k];
51
+ return `docs:page:${locale}:${routeId}:${JSON.stringify(sorted)}`;
52
+ };
53
+ export const getDocs = (ctx) => {
54
+ const { event, projectId, store, respond } = ctx;
55
+ const url = event.url;
56
+ const kind = url.searchParams.get('kind');
57
+ if (kind !== 'page' && kind !== 'layout') {
58
+ return badRequest('kind must be "page" or "layout"');
59
+ }
60
+ const routeId = url.searchParams.get('routeId');
61
+ if (!routeId)
62
+ return badRequest('routeId required');
63
+ const params = parseParams(url.searchParams.get('params'));
64
+ if (!params)
65
+ return badRequest('params must be a JSON object of strings');
66
+ const locale = url.searchParams.get('locale') ?? DEFAULT_LOCALE;
67
+ const previewKey = url.searchParams.get('preview');
68
+ const versionKey = url.searchParams.get('version');
69
+ if (versionKey) {
70
+ const release = store.findPublishedReleaseByPreviewKey(projectId, versionKey);
71
+ if (!release)
72
+ return new Response('release not found', { status: 404 });
73
+ const snap = store.buildStateAtRelease(projectId, release.id);
74
+ if (!snap)
75
+ return new Response('release not found', { status: 404 });
76
+ if (kind === 'page') {
77
+ const entry = findPageEntry(snap.pageSnap[locale]?.[routeId], params);
78
+ if (entry?.tombstone)
79
+ return respond.serveImmutableJson(event, entry.tombstone);
80
+ const contents = entry?.published ?? null;
81
+ if (!contents)
82
+ return respond.serveImmutableNotFound(event);
83
+ return respond.serveImmutableJson(event, { contents });
84
+ }
85
+ const contents = snap.layoutSnap[locale]?.[routeId] ?? null;
86
+ if (!contents)
87
+ return respond.serveImmutableNotFound(event);
88
+ return respond.serveImmutableJson(event, { contents });
89
+ }
90
+ // Preview path: editor-only, low volume, bypass all cache layers.
91
+ if (previewKey) {
92
+ const base = kind === 'page' ? store.getPublishedPageEntry(projectId, locale, routeId, params) : null;
93
+ const layoutBase = kind === 'layout' ? store.getPublishedLayoutTree(projectId, locale, routeId) : null;
94
+ const release = store.findOpenReleaseByPreviewKey(projectId, previewKey);
95
+ if (release) {
96
+ if (kind === 'page') {
97
+ const pageDelete = release.items.find((i) => i.kind === 'page-delete' &&
98
+ i.routeId === routeId &&
99
+ i.locale === locale &&
100
+ paramsEqual(i.params, params));
101
+ if (pageDelete && pageDelete.kind === 'page-delete') {
102
+ if (pageDelete.outcome)
103
+ return Response.json(pageDelete.outcome);
104
+ return new Response(null, { status: 404 });
105
+ }
106
+ }
107
+ let contents = base?.published
108
+ ? { ...base.published }
109
+ : layoutBase
110
+ ? { ...layoutBase }
111
+ : null;
112
+ for (const item of release.items) {
113
+ if (item.kind !== kind || item.routeId !== routeId)
114
+ continue;
115
+ if (item.locale !== locale)
116
+ continue;
117
+ if (kind === 'page' && item.kind === 'page' && !paramsEqual(item.params, params))
118
+ continue;
119
+ contents = mergeTree(contents ?? {}, item.tree);
120
+ }
121
+ if (!contents) {
122
+ if (base?.tombstone)
123
+ return Response.json(base.tombstone);
124
+ return new Response(null, { status: 404 });
125
+ }
126
+ return Response.json({ contents });
127
+ }
128
+ // Preview key didn't match — fall through to the public path so we
129
+ // still serve published content (and apply caching).
130
+ }
131
+ const scope = cacheScope(kind, locale, routeId, params);
132
+ const cacheCheck = respond.tryPublicCache(event, projectId, scope);
133
+ if (cacheCheck.hit)
134
+ return cacheCheck.response;
135
+ let base;
136
+ let baseTombstone;
137
+ if (kind === 'page') {
138
+ const entry = store.getPublishedPageEntry(projectId, locale, routeId, params);
139
+ base = entry?.published;
140
+ baseTombstone = entry?.tombstone;
141
+ }
142
+ else {
143
+ base = store.getPublishedLayoutTree(projectId, locale, routeId) ?? undefined;
144
+ }
145
+ if (baseTombstone)
146
+ return respond.servePublicJson(event, projectId, scope, cacheCheck, baseTombstone);
147
+ if (!base)
148
+ return respond.servePublicNotFound(event, cacheCheck);
149
+ return respond.servePublicJson(event, projectId, scope, cacheCheck, { contents: base });
150
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The sign-in flow the admin bar embeds in an iframe.
3
+ *
4
+ * This replaces five files in the original — two `+page.server.ts`, two
5
+ * `+page.svelte`, and a `+layout.server.ts` — plus a zod schema, and drops
6
+ * `sveltekit-superforms`, `zod`, and four shadcn components from the surface an
7
+ * application has to vendor. Serving plain HTML from the handler means the
8
+ * login works identically in an app with any UI kit, or none.
9
+ *
10
+ * The flow: the admin bar opens `<endpoint>/iframe/login` in an iframe, the
11
+ * form posts back to the same URL, and on success the page tells the parent
12
+ * window so the bar can re-fetch `/user` without a reload.
13
+ */
14
+ import type { RouteCtx } from './context.js';
15
+ export declare const getLogin: (ctx: RouteCtx) => Response;
16
+ export declare const postLogin: (ctx: RouteCtx) => Promise<Response>;
17
+ /** Kept so the admin bar's historical `/iframe/login/success` URL still
18
+ * resolves; the POST above renders the same screen inline. */
19
+ export declare const getLoginSuccess: (ctx: RouteCtx) => Response;