@glw907/cairn-cms 0.1.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/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/adapter.d.ts +60 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +30 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +93 -0
- package/dist/carta.d.ts +39 -0
- package/dist/carta.d.ts.map +1 -0
- package/dist/carta.js +30 -0
- package/dist/components/AdminLayout.svelte +18 -0
- package/dist/components/AdminLayout.svelte.d.ts +8 -0
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -0
- package/dist/components/AdminList.svelte +41 -0
- package/dist/components/AdminList.svelte.d.ts +13 -0
- package/dist/components/AdminList.svelte.d.ts.map +1 -0
- package/dist/components/EditPage.svelte +125 -0
- package/dist/components/EditPage.svelte.d.ts +13 -0
- package/dist/components/EditPage.svelte.d.ts.map +1 -0
- package/dist/components/LoginPage.svelte +47 -0
- package/dist/components/LoginPage.svelte.d.ts +11 -0
- package/dist/components/LoginPage.svelte.d.ts.map +1 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/content.d.ts +3 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +10 -0
- package/dist/email.d.ts +14 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +17 -0
- package/dist/github.d.ts +52 -0
- package/dist/github.d.ts.map +1 -0
- package/dist/github.js +136 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/sveltekit/index.d.ts +91 -0
- package/dist/sveltekit/index.d.ts.map +1 -0
- package/dist/sveltekit/index.js +163 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +11 -0
- package/package.json +79 -0
- package/src/lib/adapter.ts +110 -0
- package/src/lib/auth.ts +130 -0
- package/src/lib/carta.ts +59 -0
- package/src/lib/components/AdminLayout.svelte +18 -0
- package/src/lib/components/AdminList.svelte +41 -0
- package/src/lib/components/EditPage.svelte +125 -0
- package/src/lib/components/LoginPage.svelte +47 -0
- package/src/lib/components/index.ts +6 -0
- package/src/lib/content.ts +11 -0
- package/src/lib/email.ts +35 -0
- package/src/lib/github.ts +188 -0
- package/src/lib/index.ts +7 -0
- package/src/lib/sveltekit/index.ts +272 -0
- package/src/lib/utils.ts +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Geoff Wright
|
|
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
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# cairn-cms
|
|
2
|
+
|
|
3
|
+
An embedded, **magic-link**, GitHub-committing CMS for SvelteKit + Cloudflare sites.
|
|
4
|
+
Non-technical authors log in by email (no GitHub account, no password), edit **raw
|
|
5
|
+
markdown** in a [Carta](https://github.com/BearToCode/carta) editor, and save — which
|
|
6
|
+
commits to `main` via a **GitHub App** (committer = `cairn-cms[bot]`, author = the editor)
|
|
7
|
+
and auto-deploys.
|
|
8
|
+
|
|
9
|
+
It is **design-agnostic**: each consumer site supplies an adapter (collections, slug
|
|
10
|
+
convention, frontmatter schema, and its own `renderPreview(md)`), so the same engine drives
|
|
11
|
+
sites with completely different markdown pipelines — e.g. [ecnordic.ski](https://ecnordic.ski)
|
|
12
|
+
(remark→rehype directive pipeline) and [907.life](https://907.life) (plain `remark-html`).
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
**Early (`0.1.x`) — works, API not yet frozen.** The core was built *inside ecnordic.ski first*
|
|
17
|
+
(the richer proving ground) with the cairn-core ↔ site-adapter seams designed in from day one,
|
|
18
|
+
then extracted into this package and validated on a second design (907.life). The auth, GitHub
|
|
19
|
+
commit path, Carta preview, the adapter contract, and the shared admin shell (`/sveltekit`
|
|
20
|
+
server logic + `/components` Svelte UI) all run on both sites. The adapter API may still change
|
|
21
|
+
before `1.0` (pending a forward-compatibility review) — pin a caret range and expect 0.x churn.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install @glw907/cairn-cms
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Peers: `svelte@^5`, `@sveltejs/kit@^2`, and `carta-md@^4.11` (the editor component). Each site
|
|
30
|
+
implements a `CairnAdapter` (see `docs/PLAN.md`) and mounts thin `/admin` route shims around
|
|
31
|
+
`@glw907/cairn-cms/sveltekit` (server logic) and `@glw907/cairn-cms/components` (the admin UI).
|
|
32
|
+
|
|
33
|
+
## How it's developed
|
|
34
|
+
|
|
35
|
+
This repo lives in a dev meta-workspace alongside its consumer sites:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
~/Projects/cairn/ # npm workspace root (not a git repo)
|
|
39
|
+
cairn-cms/ ← this repo
|
|
40
|
+
ecnordic-ski/ ← consumer (first proving ground)
|
|
41
|
+
907-life/ ← consumer (second design, validates the abstraction)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
npm workspaces symlink `cairn-cms` into each site's `node_modules` for zero-publish local
|
|
45
|
+
dev. In CI, each site pins a published version so deploys stay reproducible.
|
|
46
|
+
|
|
47
|
+
See **`docs/PLAN.md`** for the full architecture, locked decisions, phased passes, and
|
|
48
|
+
risk register.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { PreviewPlugins } from './carta';
|
|
2
|
+
import type { RepoRef } from './github';
|
|
3
|
+
interface FieldBase {
|
|
4
|
+
/** Frontmatter key and form input name. */
|
|
5
|
+
name: string;
|
|
6
|
+
label: string;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface TextField extends FieldBase {
|
|
10
|
+
type: 'text';
|
|
11
|
+
}
|
|
12
|
+
export interface DateField extends FieldBase {
|
|
13
|
+
type: 'date';
|
|
14
|
+
}
|
|
15
|
+
export interface TextareaField extends FieldBase {
|
|
16
|
+
type: 'textarea';
|
|
17
|
+
rows?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface BooleanField extends FieldBase {
|
|
20
|
+
type: 'boolean';
|
|
21
|
+
}
|
|
22
|
+
export interface TagsField extends FieldBase {
|
|
23
|
+
type: 'tags';
|
|
24
|
+
/** Controlled vocabulary rendered as checkboxes. */
|
|
25
|
+
options: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
export interface FreeTagsField extends FieldBase {
|
|
28
|
+
type: 'freetags';
|
|
29
|
+
/** Free-form tags, edited as one comma-separated text input (no controlled vocabulary). */
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
}
|
|
32
|
+
export type CairnField = TextField | DateField | TextareaField | BooleanField | TagsField | FreeTagsField;
|
|
33
|
+
export interface CairnCollection {
|
|
34
|
+
/** Route `[type]` segment and list key, e.g. `posts`. */
|
|
35
|
+
type: string;
|
|
36
|
+
label: string;
|
|
37
|
+
/** Repo-relative folder holding the collection's markdown files. */
|
|
38
|
+
dir: string;
|
|
39
|
+
/** Editor form fields, rendered in order. */
|
|
40
|
+
fields: CairnField[];
|
|
41
|
+
/** Validate raw frontmatter (from the form) into the on-disk object, throwing on error. */
|
|
42
|
+
validate(data: Record<string, unknown>, source: string): object;
|
|
43
|
+
}
|
|
44
|
+
export interface CairnAdapter {
|
|
45
|
+
/** Branding + magic-link email copy. */
|
|
46
|
+
siteName: string;
|
|
47
|
+
/** From: address for magic-link email — a domain-authenticated sender. */
|
|
48
|
+
sender: string;
|
|
49
|
+
/** The repository the admin reads content from and commits to. */
|
|
50
|
+
backend: RepoRef;
|
|
51
|
+
/** Site plugin set for the Carta preview (parity with the live render). */
|
|
52
|
+
preview: PreviewPlugins;
|
|
53
|
+
collections: CairnCollection[];
|
|
54
|
+
}
|
|
55
|
+
/** Look up a collection by its route segment, or undefined if the segment is unknown. */
|
|
56
|
+
export declare function findCollection(adapter: CairnAdapter, type: string): CairnCollection | undefined;
|
|
57
|
+
/** Read raw frontmatter from a submitted form, decoding each value per its field type. */
|
|
58
|
+
export declare function frontmatterFromForm(collection: CairnCollection, form: FormData): Record<string, unknown>;
|
|
59
|
+
export {};
|
|
60
|
+
//# sourceMappingURL=adapter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/lib/adapter.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAExC,UAAU,SAAS;IACjB,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,SAAU,SAAQ,SAAS;IAC1C,IAAI,EAAE,MAAM,CAAC;CACd;AACD,MAAM,WAAW,SAAU,SAAQ,SAAS;IAC1C,IAAI,EAAE,MAAM,CAAC;CACd;AACD,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,IAAI,EAAE,UAAU,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AACD,MAAM,WAAW,YAAa,SAAQ,SAAS;IAC7C,IAAI,EAAE,SAAS,CAAC;CACjB;AACD,MAAM,WAAW,SAAU,SAAQ,SAAS;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CAC5B;AACD,MAAM,WAAW,aAAc,SAAQ,SAAS;IAC9C,IAAI,EAAE,UAAU,CAAC;IACjB,2FAA2F;IAC3F,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,MAAM,UAAU,GAClB,SAAS,GACT,SAAS,GACT,aAAa,GACb,YAAY,GACZ,SAAS,GACT,aAAa,CAAC;AAElB,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,oEAAoE;IACpE,GAAG,EAAE,MAAM,CAAC;IACZ,6CAA6C;IAC7C,MAAM,EAAE,UAAU,EAAE,CAAC;IACrB,2FAA2F;IAC3F,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;CACjE;AAED,MAAM,WAAW,YAAY;IAC3B,wCAAwC;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,OAAO,EAAE,OAAO,CAAC;IACjB,2EAA2E;IAC3E,OAAO,EAAE,cAAc,CAAC;IACxB,WAAW,EAAE,eAAe,EAAE,CAAC;CAChC;AAED,yFAAyF;AACzF,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAE/F;AAED,0FAA0F;AAC1F,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,eAAe,EAC3B,IAAI,EAAE,QAAQ,GACb,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA0BzB"}
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Look up a collection by its route segment, or undefined if the segment is unknown. */
|
|
2
|
+
export function findCollection(adapter, type) {
|
|
3
|
+
return adapter.collections.find((collection) => collection.type === type);
|
|
4
|
+
}
|
|
5
|
+
/** Read raw frontmatter from a submitted form, decoding each value per its field type. */
|
|
6
|
+
export function frontmatterFromForm(collection, form) {
|
|
7
|
+
const data = {};
|
|
8
|
+
for (const field of collection.fields) {
|
|
9
|
+
switch (field.type) {
|
|
10
|
+
case 'boolean':
|
|
11
|
+
data[field.name] = form.get(field.name) === 'on';
|
|
12
|
+
break;
|
|
13
|
+
case 'tags':
|
|
14
|
+
data[field.name] = form.getAll(field.name).map(String);
|
|
15
|
+
break;
|
|
16
|
+
case 'freetags':
|
|
17
|
+
// One comma-separated input → trimmed, de-duplicated, non-empty tags.
|
|
18
|
+
data[field.name] = [
|
|
19
|
+
...new Set(String(form.get(field.name) ?? '')
|
|
20
|
+
.split(',')
|
|
21
|
+
.map((tag) => tag.trim())
|
|
22
|
+
.filter(Boolean)),
|
|
23
|
+
];
|
|
24
|
+
break;
|
|
25
|
+
default:
|
|
26
|
+
data[field.name] = form.get(field.name);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return data;
|
|
30
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { KVNamespace } from '@cloudflare/workers-types';
|
|
2
|
+
export interface Editor {
|
|
3
|
+
email: string;
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const SESSION_COOKIE = "cairn_session";
|
|
7
|
+
export declare const SESSION_MAX_AGE: number;
|
|
8
|
+
/** Issue a single-use magic-link token and register its nonce in KV with a TTL. */
|
|
9
|
+
export declare function createMagicLink(email: string, secret: string, kv: KVNamespace): Promise<string>;
|
|
10
|
+
/** Redeem a magic-link token: verify, check expiry, then consume the KV nonce (single use). */
|
|
11
|
+
export declare function redeemMagicToken(token: string, secret: string, kv: KVNamespace): Promise<string | null>;
|
|
12
|
+
export declare function createSession(editor: Editor, secret: string): Promise<string>;
|
|
13
|
+
export declare function verifySession(token: string, secret: string): Promise<Editor | null>;
|
|
14
|
+
/** Look up an editor in the KV allowlist (`editor:<email>` → display name). */
|
|
15
|
+
export declare function lookupEditor(email: string, kv: KVNamespace): Promise<Editor | null>;
|
|
16
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/lib/auth.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAG7D,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,eAAO,MAAM,cAAc,kBAAkB,CAAC;AAK9C,eAAO,MAAM,eAAe,QAAsB,CAAC;AA8DnD,mFAAmF;AACnF,wBAAsB,eAAe,CACnC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED,+FAA+F;AAC/F,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,WAAW,GACd,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAOxB;AAMD,wBAAsB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGnF;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAIzF;AAED,+EAA+E;AAC/E,wBAAsB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAKzF"}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// cairn-core: magic-link auth + signed sessions.
|
|
2
|
+
//
|
|
3
|
+
// Generic across sites — no ecnordic specifics here. Crypto is Web Crypto (HMAC-SHA256)
|
|
4
|
+
// so it runs unchanged on Cloudflare Workers under nodejs_compat. Single-use enforcement
|
|
5
|
+
// for magic links rides on a KV nonce; signature + expiry are self-contained in the token.
|
|
6
|
+
import { bytesToB64url } from './utils';
|
|
7
|
+
export const SESSION_COOKIE = 'cairn_session';
|
|
8
|
+
const MAGIC_TTL_SECONDS = 600; // 10 minutes
|
|
9
|
+
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
10
|
+
export const SESSION_MAX_AGE = SESSION_TTL_SECONDS;
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
function b64urlToBytes(value) {
|
|
14
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
15
|
+
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
16
|
+
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
|
17
|
+
}
|
|
18
|
+
// TextEncoder/atob produce Uint8Arrays whose generic buffer type no longer satisfies
|
|
19
|
+
// Web Crypto's BufferSource under strict lib types; hand the underlying ArrayBuffer over.
|
|
20
|
+
function buf(bytes) {
|
|
21
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
22
|
+
}
|
|
23
|
+
async function hmacKey(secret) {
|
|
24
|
+
return crypto.subtle.importKey('raw', buf(encoder.encode(secret)), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
|
25
|
+
}
|
|
26
|
+
/** Sign an arbitrary JSON payload as `<base64url(payload)>.<base64url(hmac)>`. */
|
|
27
|
+
async function signToken(data, secret) {
|
|
28
|
+
const payload = bytesToB64url(encoder.encode(JSON.stringify(data)));
|
|
29
|
+
const key = await hmacKey(secret);
|
|
30
|
+
const sig = await crypto.subtle.sign('HMAC', key, buf(encoder.encode(payload)));
|
|
31
|
+
return `${payload}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
32
|
+
}
|
|
33
|
+
/** Verify signature (constant-time via subtle.verify) and parse the payload, or null. */
|
|
34
|
+
async function verifyToken(token, secret) {
|
|
35
|
+
const dot = token.indexOf('.');
|
|
36
|
+
if (dot < 0)
|
|
37
|
+
return null;
|
|
38
|
+
const payload = token.slice(0, dot);
|
|
39
|
+
const sig = token.slice(dot + 1);
|
|
40
|
+
const key = await hmacKey(secret);
|
|
41
|
+
let ok = false;
|
|
42
|
+
try {
|
|
43
|
+
ok = await crypto.subtle.verify('HMAC', key, buf(b64urlToBytes(sig)), buf(encoder.encode(payload)));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!ok)
|
|
49
|
+
return null;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(decoder.decode(b64urlToBytes(payload)));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Issue a single-use magic-link token and register its nonce in KV with a TTL. */
|
|
58
|
+
export async function createMagicLink(email, secret, kv) {
|
|
59
|
+
const nonce = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
|
|
60
|
+
const exp = Date.now() + MAGIC_TTL_SECONDS * 1000;
|
|
61
|
+
const token = await signToken({ email, exp, nonce }, secret);
|
|
62
|
+
await kv.put(`ml:${nonce}`, email, { expirationTtl: MAGIC_TTL_SECONDS });
|
|
63
|
+
return token;
|
|
64
|
+
}
|
|
65
|
+
/** Redeem a magic-link token: verify, check expiry, then consume the KV nonce (single use). */
|
|
66
|
+
export async function redeemMagicToken(token, secret, kv) {
|
|
67
|
+
const payload = await verifyToken(token, secret);
|
|
68
|
+
if (!payload || Date.now() > payload.exp)
|
|
69
|
+
return null;
|
|
70
|
+
const stored = await kv.get(`ml:${payload.nonce}`);
|
|
71
|
+
if (stored !== payload.email)
|
|
72
|
+
return null;
|
|
73
|
+
await kv.delete(`ml:${payload.nonce}`); // burn it — single use
|
|
74
|
+
return payload.email;
|
|
75
|
+
}
|
|
76
|
+
export async function createSession(editor, secret) {
|
|
77
|
+
const exp = Date.now() + SESSION_TTL_SECONDS * 1000;
|
|
78
|
+
return signToken({ ...editor, exp }, secret);
|
|
79
|
+
}
|
|
80
|
+
export async function verifySession(token, secret) {
|
|
81
|
+
const payload = await verifyToken(token, secret);
|
|
82
|
+
if (!payload || Date.now() > payload.exp)
|
|
83
|
+
return null;
|
|
84
|
+
return { email: payload.email, name: payload.name };
|
|
85
|
+
}
|
|
86
|
+
/** Look up an editor in the KV allowlist (`editor:<email>` → display name). */
|
|
87
|
+
export async function lookupEditor(email, kv) {
|
|
88
|
+
const normalized = email.trim().toLowerCase();
|
|
89
|
+
const name = await kv.get(`editor:${normalized}`);
|
|
90
|
+
if (name === null)
|
|
91
|
+
return null;
|
|
92
|
+
return { email: normalized, name };
|
|
93
|
+
}
|
package/dist/carta.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Pluggable, Processor } from 'unified';
|
|
2
|
+
export interface PreviewPlugins {
|
|
3
|
+
/** remark plugins, injected after gfm and before remark-rehype. */
|
|
4
|
+
remarkPlugins: Pluggable[];
|
|
5
|
+
/** rehype plugins, injected after remark-rehype. */
|
|
6
|
+
rehypePlugins: Pluggable[];
|
|
7
|
+
}
|
|
8
|
+
interface PreviewTransformer {
|
|
9
|
+
execution: 'sync';
|
|
10
|
+
type: 'remark' | 'rehype';
|
|
11
|
+
transform: (ctx: {
|
|
12
|
+
processor: Processor;
|
|
13
|
+
}) => void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Map the site's plugin set to Carta sync transformers, remark phase before rehype.
|
|
17
|
+
* Carta's processor is remarkParse → gfm → [remark] → remark-rehype → [rehype] → stringify,
|
|
18
|
+
* so this ordering reproduces render.ts exactly. Pure (no Carta) so it is unit-testable.
|
|
19
|
+
*/
|
|
20
|
+
export declare function previewTransformers({ remarkPlugins, rehypePlugins }: PreviewPlugins): PreviewTransformer[];
|
|
21
|
+
/** Minimal Options subset we populate — avoids importing carta-md (Svelte re-exports). */
|
|
22
|
+
interface PreviewCartaOptions {
|
|
23
|
+
sanitizer: false;
|
|
24
|
+
rehypeOptions: {
|
|
25
|
+
allowDangerousHtml: boolean;
|
|
26
|
+
};
|
|
27
|
+
extensions: Array<{
|
|
28
|
+
transformers: PreviewTransformer[];
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Carta options for a render-only preview: site plugins wired in, raw HTML allowed, no
|
|
33
|
+
* sanitizer. Authors are trusted and the directive pipeline emits intentional raw HTML
|
|
34
|
+
* (render.ts uses allowDangerousHtml + rehype-raw); sanitizing here would strip EC
|
|
35
|
+
* primitives. The Svelte component passes this to `new Carta(...)`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function previewCartaOptions(plugins: PreviewPlugins): PreviewCartaOptions;
|
|
38
|
+
export {};
|
|
39
|
+
//# sourceMappingURL=carta.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"carta.d.ts","sourceRoot":"","sources":["../src/lib/carta.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAEpD,MAAM,WAAW,cAAc;IAC7B,mEAAmE;IACnE,aAAa,EAAE,SAAS,EAAE,CAAC;IAC3B,oDAAoD;IACpD,aAAa,EAAE,SAAS,EAAE,CAAC;CAC5B;AAED,UAAU,kBAAkB;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAC1B,SAAS,EAAE,CAAC,GAAG,EAAE;QAAE,SAAS,EAAE,SAAS,CAAA;KAAE,KAAK,IAAI,CAAC;CACpD;AAYD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,aAAa,EAAE,aAAa,EAAE,EAAE,cAAc,GAAG,kBAAkB,EAAE,CAE1G;AAED,0FAA0F;AAC1F,UAAU,mBAAmB;IAC3B,SAAS,EAAE,KAAK,CAAC;IACjB,aAAa,EAAE;QAAE,kBAAkB,EAAE,OAAO,CAAA;KAAE,CAAC;IAC/C,UAAU,EAAE,KAAK,CAAC;QAAE,YAAY,EAAE,kBAAkB,EAAE,CAAA;KAAE,CAAC,CAAC;CAC3D;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,cAAc,GAAG,mBAAmB,CAMhF"}
|
package/dist/carta.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
function phase(plugins, type) {
|
|
2
|
+
return plugins.map((plugin) => ({
|
|
3
|
+
execution: 'sync',
|
|
4
|
+
type,
|
|
5
|
+
transform: ({ processor }) => {
|
|
6
|
+
processor.use([plugin]);
|
|
7
|
+
},
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Map the site's plugin set to Carta sync transformers, remark phase before rehype.
|
|
12
|
+
* Carta's processor is remarkParse → gfm → [remark] → remark-rehype → [rehype] → stringify,
|
|
13
|
+
* so this ordering reproduces render.ts exactly. Pure (no Carta) so it is unit-testable.
|
|
14
|
+
*/
|
|
15
|
+
export function previewTransformers({ remarkPlugins, rehypePlugins }) {
|
|
16
|
+
return [...phase(remarkPlugins, 'remark'), ...phase(rehypePlugins, 'rehype')];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Carta options for a render-only preview: site plugins wired in, raw HTML allowed, no
|
|
20
|
+
* sanitizer. Authors are trusted and the directive pipeline emits intentional raw HTML
|
|
21
|
+
* (render.ts uses allowDangerousHtml + rehype-raw); sanitizing here would strip EC
|
|
22
|
+
* primitives. The Svelte component passes this to `new Carta(...)`.
|
|
23
|
+
*/
|
|
24
|
+
export function previewCartaOptions(plugins) {
|
|
25
|
+
return {
|
|
26
|
+
sanitizer: false,
|
|
27
|
+
rehypeOptions: { allowDangerousHtml: true },
|
|
28
|
+
extensions: [{ transformers: previewTransformers(plugins) }],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Neutral admin chrome — robots noindex + a centered container, scoped to /admin. Shared
|
|
3
|
+
// across sites so the admin tool looks identical everywhere (only siteName, supplied by
|
|
4
|
+
// pages, varies). Each site's `admin/+layout.svelte` is a one-line shim around this.
|
|
5
|
+
import type { Snippet } from 'svelte';
|
|
6
|
+
|
|
7
|
+
let { children }: { children: Snippet } = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<svelte:head>
|
|
11
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
12
|
+
</svelte:head>
|
|
13
|
+
|
|
14
|
+
<div class="min-h-screen bg-base-200" data-pagefind-ignore>
|
|
15
|
+
<div class="mx-auto max-w-3xl px-4 py-8">
|
|
16
|
+
{@render children()}
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
children: Snippet;
|
|
4
|
+
};
|
|
5
|
+
declare const AdminLayout: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type AdminLayout = ReturnType<typeof AdminLayout>;
|
|
7
|
+
export default AdminLayout;
|
|
8
|
+
//# sourceMappingURL=AdminLayout.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AdminLayout.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminLayout.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAErC,KAAK,gBAAgB,GAAI;IAAE,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAC;AAsBhD,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The /admin content list: every collection's files, linking into the editor. Data comes
|
|
3
|
+
// from `adminListLoad` (collections) merged with `adminLayoutLoad` (editor, siteName).
|
|
4
|
+
import type { Editor } from '../auth';
|
|
5
|
+
import type { AdminCollectionList } from '../sveltekit';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
data: { siteName: string; editor: Editor | null; collections: AdminCollectionList[] };
|
|
9
|
+
}
|
|
10
|
+
let { data }: Props = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div class="flex items-center justify-between">
|
|
14
|
+
<h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
|
|
15
|
+
<form method="POST" action="/admin/auth/logout">
|
|
16
|
+
<button type="submit" class="btn btn-ghost btn-sm">Sign out</button>
|
|
17
|
+
</form>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<p class="mt-2 text-sm opacity-70">
|
|
21
|
+
Signed in as {data.editor?.name} ({data.editor?.email})
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
{#each data.collections as collection (collection.type)}
|
|
25
|
+
<section class="mt-8">
|
|
26
|
+
<h2 class="mb-3 text-lg font-semibold">{collection.label}</h2>
|
|
27
|
+
{#if collection.error}
|
|
28
|
+
<div class="alert alert-warning">Couldn't load {collection.label.toLowerCase()}: {collection.error}</div>
|
|
29
|
+
{:else if collection.files.length === 0}
|
|
30
|
+
<p class="opacity-60">No content yet.</p>
|
|
31
|
+
{:else}
|
|
32
|
+
<ul class="menu rounded-box border border-base-300 bg-base-100 p-2">
|
|
33
|
+
{#each collection.files as file (file.path)}
|
|
34
|
+
<li>
|
|
35
|
+
<a href="/admin/edit/{collection.type}/{file.id}">{file.id}</a>
|
|
36
|
+
</li>
|
|
37
|
+
{/each}
|
|
38
|
+
</ul>
|
|
39
|
+
{/if}
|
|
40
|
+
</section>
|
|
41
|
+
{/each}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Editor } from '../auth';
|
|
2
|
+
import type { AdminCollectionList } from '../sveltekit';
|
|
3
|
+
interface Props {
|
|
4
|
+
data: {
|
|
5
|
+
siteName: string;
|
|
6
|
+
editor: Editor | null;
|
|
7
|
+
collections: AdminCollectionList[];
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
declare const AdminList: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type AdminList = ReturnType<typeof AdminList>;
|
|
12
|
+
export default AdminList;
|
|
13
|
+
//# sourceMappingURL=AdminList.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AdminList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AdminList.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACtC,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAGtD,UAAU,KAAK;IACb,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,WAAW,EAAE,mBAAmB,EAAE,CAAA;KAAE,CAAC;CACvF;AA0CH,QAAA,MAAM,SAAS,2CAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The editor: a per-field frontmatter form (driven by the adapter's `fields`) plus a Carta
|
|
3
|
+
// markdown editor whose preview runs the site's plugin set (passed as `preview`). Data comes
|
|
4
|
+
// from `editLoad` merged with `adminLayoutLoad` (siteName); `carta-md` is a peer dependency.
|
|
5
|
+
import { onMount } from 'svelte';
|
|
6
|
+
import { Carta, MarkdownEditor } from 'carta-md';
|
|
7
|
+
import 'carta-md/default.css';
|
|
8
|
+
import { previewCartaOptions, type PreviewPlugins } from '../carta';
|
|
9
|
+
import type { CairnField } from '../adapter';
|
|
10
|
+
import type { EditData } from '../sveltekit';
|
|
11
|
+
|
|
12
|
+
let { data, preview }: { data: EditData & { siteName: string }; preview: PreviewPlugins } = $props();
|
|
13
|
+
|
|
14
|
+
// Body is editable state; the Carta editor's preview runs the exact site plugin set, so it
|
|
15
|
+
// matches the live page. A hidden input carries the current value into the form.
|
|
16
|
+
// svelte-ignore state_referenced_locally — seeding from the initial load is intended.
|
|
17
|
+
let body = $state(data.body);
|
|
18
|
+
|
|
19
|
+
// svelte-ignore state_referenced_locally — the preview plugin set is fixed for the load.
|
|
20
|
+
const carta = new Carta(previewCartaOptions(preview));
|
|
21
|
+
|
|
22
|
+
// Carta's MarkdownEditor must not render on the worker (it pulls Shiki). onMount fires only
|
|
23
|
+
// in the browser, so SSR renders the plain textarea and the client swaps in the editor —
|
|
24
|
+
// the kit-free equivalent of the per-site route's `$app/environment` `browser` guard.
|
|
25
|
+
let mounted = $state(false);
|
|
26
|
+
onMount(() => {
|
|
27
|
+
mounted = true;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// svelte-ignore state_referenced_locally — form defaults from the initial load.
|
|
31
|
+
const fm = data.frontmatter as Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
function fmString(key: string): string {
|
|
34
|
+
return typeof fm[key] === 'string' ? (fm[key] as string) : '';
|
|
35
|
+
}
|
|
36
|
+
function fmTags(key: string): Set<string> {
|
|
37
|
+
return new Set(Array.isArray(fm[key]) ? (fm[key] as unknown[]).map(String) : []);
|
|
38
|
+
}
|
|
39
|
+
function fmFreeTags(key: string): string {
|
|
40
|
+
return Array.isArray(fm[key]) ? (fm[key] as unknown[]).map(String).join(', ') : '';
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<svelte:head>
|
|
45
|
+
<title>Edit {data.title} · {data.siteName} CMS</title>
|
|
46
|
+
</svelte:head>
|
|
47
|
+
|
|
48
|
+
<div class="flex items-center justify-between gap-4">
|
|
49
|
+
<div>
|
|
50
|
+
<a href="/admin" class="text-sm opacity-70 hover:underline">← Back</a>
|
|
51
|
+
<h1 class="mt-1 text-2xl font-bold">{data.title}</h1>
|
|
52
|
+
<p class="text-sm opacity-60">{data.label} · {data.path}</p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{#if data.saved}
|
|
57
|
+
<div class="alert alert-success mt-6"><span>Saved — committed to main; the site will redeploy.</span></div>
|
|
58
|
+
{:else if data.error}
|
|
59
|
+
<div class="alert alert-error mt-6"><span>{data.error}</span></div>
|
|
60
|
+
{/if}
|
|
61
|
+
|
|
62
|
+
<form method="POST" action="/admin/save" class="mt-6 flex flex-col gap-5">
|
|
63
|
+
<input type="hidden" name="type" value={data.type} />
|
|
64
|
+
<input type="hidden" name="id" value={data.id} />
|
|
65
|
+
|
|
66
|
+
<fieldset class="grid gap-4 rounded-box border border-base-300 bg-base-100 p-6">
|
|
67
|
+
{#each data.fields as field (field.name)}
|
|
68
|
+
{#if field.type === 'text' || field.type === 'date'}
|
|
69
|
+
<label class="flex flex-col gap-1">
|
|
70
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
71
|
+
<input
|
|
72
|
+
type={field.type === 'date' ? 'date' : 'text'}
|
|
73
|
+
name={field.name}
|
|
74
|
+
required={field.required}
|
|
75
|
+
value={fmString(field.name)}
|
|
76
|
+
class="input input-bordered w-full"
|
|
77
|
+
/>
|
|
78
|
+
</label>
|
|
79
|
+
{:else if field.type === 'textarea'}
|
|
80
|
+
<label class="flex flex-col gap-1">
|
|
81
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
82
|
+
<textarea name={field.name} required={field.required} rows={field.rows ?? 4}
|
|
83
|
+
class="textarea textarea-bordered w-full">{fmString(field.name)}</textarea>
|
|
84
|
+
</label>
|
|
85
|
+
{:else if field.type === 'tags'}
|
|
86
|
+
<div class="flex flex-col gap-1">
|
|
87
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
88
|
+
<div class="flex flex-wrap gap-3">
|
|
89
|
+
{#each field.options as option (option)}
|
|
90
|
+
<label class="flex items-center gap-2 text-sm">
|
|
91
|
+
<input type="checkbox" name={field.name} value={option}
|
|
92
|
+
checked={fmTags(field.name).has(option)} class="checkbox checkbox-sm" />
|
|
93
|
+
{option}
|
|
94
|
+
</label>
|
|
95
|
+
{/each}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
{:else if field.type === 'freetags'}
|
|
99
|
+
<label class="flex flex-col gap-1">
|
|
100
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
101
|
+
<input type="text" name={field.name} value={fmFreeTags(field.name)}
|
|
102
|
+
placeholder={field.placeholder ?? 'comma, separated'} class="input input-bordered w-full" />
|
|
103
|
+
</label>
|
|
104
|
+
{:else if field.type === 'boolean'}
|
|
105
|
+
<label class="flex items-center gap-2 text-sm font-medium">
|
|
106
|
+
<input type="checkbox" name={field.name} checked={fm[field.name] === true} class="checkbox checkbox-sm" />
|
|
107
|
+
{field.label}
|
|
108
|
+
</label>
|
|
109
|
+
{/if}
|
|
110
|
+
{/each}
|
|
111
|
+
</fieldset>
|
|
112
|
+
|
|
113
|
+
<div class="rounded-box border border-base-300 bg-base-100 p-2">
|
|
114
|
+
<input type="hidden" name="body" value={body} />
|
|
115
|
+
{#if mounted}
|
|
116
|
+
<MarkdownEditor {carta} bind:value={body} mode="tabs" />
|
|
117
|
+
{:else}
|
|
118
|
+
<textarea bind:value={body} rows="20" class="textarea textarea-bordered w-full font-mono"></textarea>
|
|
119
|
+
{/if}
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div class="flex justify-end">
|
|
123
|
+
<button type="submit" class="btn btn-primary">Save & commit</button>
|
|
124
|
+
</div>
|
|
125
|
+
</form>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import 'carta-md/default.css';
|
|
2
|
+
import { type PreviewPlugins } from '../carta';
|
|
3
|
+
import type { EditData } from '../sveltekit';
|
|
4
|
+
type $$ComponentProps = {
|
|
5
|
+
data: EditData & {
|
|
6
|
+
siteName: string;
|
|
7
|
+
};
|
|
8
|
+
preview: PreviewPlugins;
|
|
9
|
+
};
|
|
10
|
+
declare const EditPage: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type EditPage = ReturnType<typeof EditPage>;
|
|
12
|
+
export default EditPage;
|
|
13
|
+
//# sourceMappingURL=EditPage.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAQA,OAAO,sBAAsB,CAAC;AAC9B,OAAO,EAAuB,KAAK,cAAc,EAAE,MAAM,UAAU,CAAC;AAEpE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAE5C,KAAK,gBAAgB,GAAI;IAAE,IAAI,EAAE,QAAQ,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAAC,OAAO,EAAE,cAAc,CAAA;CAAE,CAAC;AAwH7F,QAAA,MAAM,QAAQ,sDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// The magic-link sign-in page. Posts the email to /admin/auth/request; `sent`/`error` come
|
|
3
|
+
// from `loginLoad` (querystring) merged with `adminLayoutLoad` (siteName).
|
|
4
|
+
interface Props {
|
|
5
|
+
data: { siteName: string; sent: boolean; error: string | null };
|
|
6
|
+
}
|
|
7
|
+
let { data }: Props = $props();
|
|
8
|
+
|
|
9
|
+
const errorMessages: Record<string, string> = {
|
|
10
|
+
invalid: 'Please enter a valid email address.',
|
|
11
|
+
denied: 'That email is not on the editor allowlist.',
|
|
12
|
+
expired: 'That sign-in link has expired or was already used. Request a new one.',
|
|
13
|
+
config: 'Sign-in is not configured. Contact the site admin.',
|
|
14
|
+
};
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<svelte:head>
|
|
18
|
+
<title>Sign in · {data.siteName} CMS</title>
|
|
19
|
+
</svelte:head>
|
|
20
|
+
|
|
21
|
+
<div class="mx-auto mt-16 max-w-md rounded-box border border-base-300 bg-base-100 p-8">
|
|
22
|
+
<h1 class="text-2xl font-bold">{data.siteName} CMS</h1>
|
|
23
|
+
<p class="mt-1 text-sm opacity-70">Sign in with your editor email.</p>
|
|
24
|
+
|
|
25
|
+
{#if data.sent}
|
|
26
|
+
<div class="alert alert-success mt-6">
|
|
27
|
+
<span>Check your inbox — a sign-in link is on its way. It expires in 10 minutes.</span>
|
|
28
|
+
</div>
|
|
29
|
+
{:else}
|
|
30
|
+
{#if data.error}
|
|
31
|
+
<div class="alert alert-error mt-6">
|
|
32
|
+
<span>{errorMessages[data.error] ?? 'Something went wrong. Try again.'}</span>
|
|
33
|
+
</div>
|
|
34
|
+
{/if}
|
|
35
|
+
<form method="POST" action="/admin/auth/request" class="mt-6 flex flex-col gap-3">
|
|
36
|
+
<input
|
|
37
|
+
type="email"
|
|
38
|
+
name="email"
|
|
39
|
+
required
|
|
40
|
+
autocomplete="email"
|
|
41
|
+
placeholder="you@example.com"
|
|
42
|
+
class="input input-bordered w-full"
|
|
43
|
+
/>
|
|
44
|
+
<button type="submit" class="btn btn-primary">Email me a sign-in link</button>
|
|
45
|
+
</form>
|
|
46
|
+
{/if}
|
|
47
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
data: {
|
|
3
|
+
siteName: string;
|
|
4
|
+
sent: boolean;
|
|
5
|
+
error: string | null;
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
declare const LoginPage: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type LoginPage = ReturnType<typeof LoginPage>;
|
|
10
|
+
export default LoginPage;
|
|
11
|
+
//# sourceMappingURL=LoginPage.svelte.d.ts.map
|