@pirateandfox/pagestack 0.0.0-stage → 0.1.0-beta.4
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/README.md +226 -2
- package/bin/invite.mjs +26 -0
- package/index.mjs +132 -0
- package/package.json +52 -4
- package/src/client/auth.ts +35 -0
- package/src/client/editor.ts +379 -0
- package/src/client/form.ts +127 -0
- package/src/client/inline.ts +113 -0
- package/src/client/invite.ts +17 -0
- package/src/client/picker.ts +95 -0
- package/src/client/preview.css +22 -0
- package/src/client/shell.css +125 -0
- package/src/client/state.ts +88 -0
- package/src/client/tree.ts +46 -0
- package/src/core/auth.ts +80 -0
- package/src/core/patch.ts +126 -0
- package/src/core/schema.ts +102 -0
- package/src/core/storage-github.ts +108 -0
- package/src/core/storage-local.ts +44 -0
- package/src/core/storage.ts +55 -0
- package/src/core/users.ts +79 -0
- package/src/routes/api/asset.ts +10 -0
- package/src/routes/api/auth.ts +55 -0
- package/src/routes/api/images.ts +49 -0
- package/src/routes/api/page.ts +35 -0
- package/src/routes/api/pages.ts +4 -0
- package/src/routes/edit-index.astro +48 -0
- package/src/routes/edit-page.astro +60 -0
- package/src/routes/enroll.astro +46 -0
- package/src/routes/login.astro +33 -0
- package/src/routes/preview.astro +25 -0
- package/src/runtime.ts +106 -0
- package/src/server/context.ts +80 -0
- package/src/server/middleware.ts +44 -0
- package/src/server/webauthn.ts +91 -0
- package/src/virtual.d.ts +12 -0
package/README.md
CHANGED
|
@@ -1,3 +1,227 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PageStack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A visual editor for Astro sites where pages are a stack of sections. Git-only: saving is a commit,
|
|
4
|
+
the public site stays static, and nothing lives outside the repo.
|
|
5
|
+
|
|
6
|
+
- The public site stays **fully static**. `astro build` is unchanged.
|
|
7
|
+
- The editor (`/edit/*`, `/api/editor/*`) is on-demand rendered and only exists in `astro dev`
|
|
8
|
+
or in a build made with `EDITOR=1` (which adds `@astrojs/node`).
|
|
9
|
+
- Pages are JSON documents in the repo (`src/content/pages/<slug>.json`). Saving writes a file
|
|
10
|
+
(dev) or makes **one commit** through the GitHub API (production). Nothing is stored anywhere else.
|
|
11
|
+
- Login is **passkeys** (WebAuthn). Users' public keys live in a committed `editor-users.json`;
|
|
12
|
+
the only server secret is `EDITOR_SECRET`. No database, no session store.
|
|
13
|
+
- Live preview renders the real page with the site's own components, in the browser's iframe.
|
|
14
|
+
|
|
15
|
+
Status: **early beta** (`0.1.0-beta.x`). The API will move; pin a version. See [ROADMAP.md](ROADMAP.md)
|
|
16
|
+
and [CHANGELOG.md](CHANGELOG.md).
|
|
17
|
+
|
|
18
|
+
## Versions
|
|
19
|
+
|
|
20
|
+
Published on npm as **`@pirateandfox/pagestack`** (betas under the `beta` dist-tag):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @pirateandfox/pagestack@beta
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or install from a git tag and bump the tag to upgrade:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
"dependencies": { "@pirateandfox/pagestack": "github:pirateandfox/pagestack#v0.1.0-beta.4" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install # after changing the tag
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Releases are tags `vX.Y.Z[-beta.N]` on `main`; each one has a CHANGELOG entry.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// astro.config.mjs
|
|
42
|
+
import pagestack from '@pirateandfox/pagestack';
|
|
43
|
+
|
|
44
|
+
export default defineConfig({
|
|
45
|
+
integrations: [
|
|
46
|
+
pagestack({
|
|
47
|
+
renderer: './src/components/SectionPage.astro', // renders { page, editing }
|
|
48
|
+
sections: './src/sections/index.ts', // exports { schemas, presets }
|
|
49
|
+
siteName: 'Acme site editor',
|
|
50
|
+
interactiveSelector: 'summary, .tabs__title, .map__load', // preview elements that keep their own click
|
|
51
|
+
auth: 'passkey', // 'passkey' | 'none-behind-proxy' | 'none' (dev only)
|
|
52
|
+
storage: process.env.EDITOR === '1' ? 'github' : 'local',
|
|
53
|
+
github: { repo: 'acme/site', branch: 'main', pullRequest: true },
|
|
54
|
+
}),
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npm run dev # editor at http://localhost:4321/edit, saves to the working tree
|
|
61
|
+
EDITOR=1 astro build # dist/server (node) + dist/client — the editor for a host
|
|
62
|
+
astro build # plain static site, no editor code
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## What the site provides
|
|
66
|
+
|
|
67
|
+
### 1. A content model: `src/content/pages/<slug>.json`
|
|
68
|
+
|
|
69
|
+
```jsonc
|
|
70
|
+
{
|
|
71
|
+
"title": "Home",
|
|
72
|
+
"description": "…",
|
|
73
|
+
"layout": { "footer": "big", "css": ["post-9.css"] }, // opaque to the editor
|
|
74
|
+
"sections": [
|
|
75
|
+
{ "id": "c8da6f4", "type": "container", "settings": { "backgroundColor": "#fff" },
|
|
76
|
+
"blocks": [
|
|
77
|
+
{ "id": "1a2b3c4", "type": "heading", "settings": { "text": "Hello", "tag": "h1" } },
|
|
78
|
+
{ "id": "5d6e7f8", "type": "text", "settings": { "html": "<p>…</p>" } }
|
|
79
|
+
] }
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A page is a tree of nodes. Top-level nodes are *sections*; anything can nest (`blocks`).
|
|
85
|
+
`attrs` is a passthrough for element attributes the editor never shows (class, aria-*).
|
|
86
|
+
|
|
87
|
+
### 2. A block registry: `src/sections/index.ts`
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import type { BlockSchema, Preset } from '@pirateandfox/pagestack/runtime';
|
|
91
|
+
export const schemas = {
|
|
92
|
+
heading: {
|
|
93
|
+
label: 'Heading',
|
|
94
|
+
fields: {
|
|
95
|
+
text: { type: 'text', label: 'Text', inline: true, default: 'Heading' },
|
|
96
|
+
tag: { type: 'select', label: 'HTML tag', options: [...], default: 'h2', group: 'advanced' },
|
|
97
|
+
color: { type: 'color', label: 'Text colour', group: 'style' },
|
|
98
|
+
},
|
|
99
|
+
component: Heading, // the Astro component
|
|
100
|
+
},
|
|
101
|
+
container: { label: 'Container', fields: {...}, blocks: '*', component: Container },
|
|
102
|
+
gallery: { label: 'Gallery', fields: {}, blocks: ['gallery-item'], component: Gallery },
|
|
103
|
+
'gallery-item': { label: 'Image', fields: {...}, hidden: true, component: GalleryItem },
|
|
104
|
+
} satisfies Record<string, BlockSchema & { component: any }>;
|
|
105
|
+
|
|
106
|
+
export const presets: Preset[] = [
|
|
107
|
+
{ label: 'Hero', description: 'Full-width image with a heading and two buttons', create: () => ({ ... }) },
|
|
108
|
+
];
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Field types: `text`, `richtext`, `image`, `link`, `color`, `select`, `number`, `boolean`,
|
|
112
|
+
`textarea`, `code`, `svg`. `inline: true` on `text` / `richtext` / `image` makes the field
|
|
113
|
+
editable by clicking it in the preview. The schema drives the sidebar form, validation on save,
|
|
114
|
+
the add-block menus and the tree labels. **Presets are the section library.**
|
|
115
|
+
|
|
116
|
+
### 3. Components that mark what they render
|
|
117
|
+
|
|
118
|
+
Each block component receives `{ node, edit }` and spreads two helpers onto its markup:
|
|
119
|
+
|
|
120
|
+
```astro
|
|
121
|
+
---
|
|
122
|
+
import { nodeAttrs, fieldAttrs } from '@pirateandfox/pagestack/runtime';
|
|
123
|
+
const { node, edit } = Astro.props;
|
|
124
|
+
---
|
|
125
|
+
<div {...nodeAttrs(edit, node)}>
|
|
126
|
+
<h2 {...fieldAttrs(edit, 'text', 'text')} set:html={node.settings.text} />
|
|
127
|
+
</div>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`edit` is `undefined` on the public site, so nothing leaks into the static output. A container
|
|
131
|
+
passes `child(edit, i)` to each child. The `renderer` component takes `{ page, editing }` and
|
|
132
|
+
renders the page inside the site's normal layout.
|
|
133
|
+
|
|
134
|
+
## Editing
|
|
135
|
+
|
|
136
|
+
- **Sidebar form** generated from the schema; live-debounced for colours and text.
|
|
137
|
+
- **Inline**: click text to edit it in place (plain text, or bold / italic / link for rich text);
|
|
138
|
+
click an image to open the picker (library browse, search, upload; alt text is required).
|
|
139
|
+
- **Structure**: tree on the left (drag to reorder sections and nested blocks), in-page toolbar
|
|
140
|
+
(move, duplicate, delete, select parent), "+ Add section" from presets, "+ Add block" from the
|
|
141
|
+
schema's allowed children.
|
|
142
|
+
- **Preview**: every change POSTs the draft document to `/edit/preview/<slug>`; the server renders
|
|
143
|
+
the full page in edit mode and the editor swaps only the sections that changed. Typically well
|
|
144
|
+
under 20 ms server-side in dev.
|
|
145
|
+
- **Drafts** are kept in `localStorage` until saved; undo/redo is per session.
|
|
146
|
+
- **Save** validates the document and either writes the file (local) or commits (github). A hash of
|
|
147
|
+
the content you loaded is sent along; if someone saved first you get a 409 and a reload prompt.
|
|
148
|
+
|
|
149
|
+
## Storage
|
|
150
|
+
|
|
151
|
+
| `storage` | Reads | Saves | Uploads |
|
|
152
|
+
|---|---|---|---|
|
|
153
|
+
| `local` (dev default) | working tree | writes the JSON file (you commit) | written to `public/images/uploads/` |
|
|
154
|
+
| `github` (build default) | Contents API on `github.branch` | one commit via the Git Data API: blobs → tree → commit → ref; with `pullRequest: true` a new `site-editor/<timestamp>-<sha>` branch + PR instead | staged in memory, served by the editor server until the next save commits them |
|
|
155
|
+
|
|
156
|
+
The GitHub token (`GITHUB_TOKEN` or `EDITOR_GITHUB_TOKEN`) is read from the environment on the
|
|
157
|
+
server only. A fine-grained token with *Contents: write* (and *Pull requests: write* for PR mode)
|
|
158
|
+
on the one repo is enough. The host that runs the editor rebuilds and deploys from the commit
|
|
159
|
+
like any other push.
|
|
160
|
+
|
|
161
|
+
## Login
|
|
162
|
+
|
|
163
|
+
### `auth: "passkey"` (default)
|
|
164
|
+
|
|
165
|
+
- `editor-users.json` (committed, path configurable with `usersFile`) holds users and the
|
|
166
|
+
**public keys** of their passkeys. Nothing in it is secret.
|
|
167
|
+
- `EDITOR_SECRET` (≥ 16 chars, environment only) signs three kinds of short-lived tokens:
|
|
168
|
+
WebAuthn challenges (5 min), the session cookie (`ase_session`, HttpOnly, SameSite=Lax, 12 h) and
|
|
169
|
+
invite links (3 days). Tokens are `base64url(json).base64url(hmac-sha256)` with the expiry inside,
|
|
170
|
+
so there is no server state.
|
|
171
|
+
- **Invites** are single-use: the invite id is recorded on the credential it created. Make the first
|
|
172
|
+
one from the CLI, later ones from the "Invite another editor" panel on `/edit`:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
EDITOR_SECRET=… npx pagestack-invite --name "Jane Doe" --email jane@example.com --origin https://example.com
|
|
176
|
+
```
|
|
177
|
+
- `/edit/enroll?invite=…` creates the passkey (Touch ID / Face ID / Windows Hello / security key);
|
|
178
|
+
`/edit/login` signs in with it. Several passkeys per person are fine (enrol again with a new invite).
|
|
179
|
+
- Set `EDITOR_ORIGIN` when a proxy rewrites `Host`; WebAuthn binds credentials to the origin the
|
|
180
|
+
browser sees.
|
|
181
|
+
- The signature counter is not persisted (it would mean a commit per login; synced passkeys report 0).
|
|
182
|
+
|
|
183
|
+
### `auth: "none-behind-proxy"`
|
|
184
|
+
|
|
185
|
+
Use when something in front of the editor already authenticates people, e.g. **Cloudflare Access**:
|
|
186
|
+
put the editor host behind an Access application for `/edit/*` and `/api/editor/*`, allow the
|
|
187
|
+
client's email addresses, and the editor trusts the proxy. It reads the identity from
|
|
188
|
+
`Cf-Access-Authenticated-User-Email` (or `X-Forwarded-User`) for commit attribution. This is a
|
|
189
|
+
good fit when the site already sits on Cloudflare and the client prefers email one-time codes over
|
|
190
|
+
passkeys. (A production deployment should also verify the Access JWT on the editor host; that's not
|
|
191
|
+
implemented here.)
|
|
192
|
+
|
|
193
|
+
### `auth: "none"`
|
|
194
|
+
|
|
195
|
+
Dev only. A build with `auth: "none"` is refused.
|
|
196
|
+
|
|
197
|
+
## Deploying the editor
|
|
198
|
+
|
|
199
|
+
The editor server is a normal `@astrojs/node` standalone build. Run `node dist/server/entry.mjs`
|
|
200
|
+
on any small Node host (Fly, Railway, a VPS…) with `EDITOR_SECRET`, `GITHUB_TOKEN` and
|
|
201
|
+
`EDITOR_ORIGIN` set, behind HTTPS. It serves the editor routes and the static pages it was built
|
|
202
|
+
with; the public site is still deployed separately from the same repo as static files. Every
|
|
203
|
+
editor response is `noindex` and `no-store`.
|
|
204
|
+
|
|
205
|
+
## Options
|
|
206
|
+
|
|
207
|
+
| option | default | |
|
|
208
|
+
|---|---|---|
|
|
209
|
+
| `renderer` | required | Astro component rendering `{ page, editing }` |
|
|
210
|
+
| `sections` | required | module exporting `{ schemas, presets }` |
|
|
211
|
+
| `pagesDir` | `src/content/pages` | |
|
|
212
|
+
| `imagesDir` / `uploadsDir` / `publicDir` | `public/images` / `public/images/uploads` / `public` | |
|
|
213
|
+
| `usersFile` | `editor-users.json` | |
|
|
214
|
+
| `auth` | `passkey` | `passkey` \| `none-behind-proxy` \| `none` |
|
|
215
|
+
| `storage` | `local` in dev, `github` in build | |
|
|
216
|
+
| `github` | — | `{ repo, branch, pullRequest }` |
|
|
217
|
+
| `enabled` | dev, or `EDITOR=1` | |
|
|
218
|
+
| `siteName`, `secretEnv` | `Site editor`, `EDITOR_SECRET` | |
|
|
219
|
+
|
|
220
|
+
## Open-source pieces used
|
|
221
|
+
|
|
222
|
+
- [SortableJS](https://github.com/SortableJS/Sortable) (MIT) for the tree drag-and-drop.
|
|
223
|
+
- [@simplewebauthn/server + browser](https://simplewebauthn.dev) (MIT) for WebAuthn.
|
|
224
|
+
- [@astrojs/node](https://docs.astro.build/en/guides/integrations-guide/node/) (MIT) as the editor's adapter.
|
|
225
|
+
- [Puck](https://puckeditor.com) was used as a design reference for JSON page editing only; no code.
|
|
226
|
+
|
|
227
|
+
MIT.
|
package/bin/invite.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Create the first invite link without a running editor (later invites can be made from /edit).
|
|
3
|
+
//
|
|
4
|
+
// EDITOR_SECRET=… node packages/pagestack/bin/invite.mjs --name "Jane Doe" --email jane@example.com --origin https://example.com [--days 3]
|
|
5
|
+
//
|
|
6
|
+
// Same token format as src/core/auth.ts (HMAC-SHA256, base64url(json).base64url(mac)) so the
|
|
7
|
+
// running site can verify it. Plain JS so it runs on any Node without a TypeScript step.
|
|
8
|
+
import { createHmac, randomBytes } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const args = Object.fromEntries(process.argv.slice(2).map((a, i, all) => a.startsWith('--') ? [a.slice(2), all[i + 1]?.startsWith('--') || all[i + 1] === undefined ? 'true' : all[i + 1]] : []).filter((p) => p.length));
|
|
11
|
+
const secret = process.env[args.secretEnv || 'EDITOR_SECRET'] || '';
|
|
12
|
+
const { name, email, origin, days = '3' } = args;
|
|
13
|
+
if (!name || !email || !origin || args.help) {
|
|
14
|
+
console.error('usage: EDITOR_SECRET=… invite.mjs --name "Jane Doe" --email jane@example.com --origin https://example.com [--days 3]');
|
|
15
|
+
process.exit(args.help ? 0 : 2);
|
|
16
|
+
}
|
|
17
|
+
if (secret.length < 16) { console.error('EDITOR_SECRET must be set in the environment (>= 16 characters)'); process.exit(2); }
|
|
18
|
+
|
|
19
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64url');
|
|
20
|
+
const ttl = Math.round(Number(days) * 86400);
|
|
21
|
+
const payload = { kind: 'invite', id: b64url(randomBytes(12)), name: name.trim(), email: email.trim().toLowerCase() };
|
|
22
|
+
const body = b64url(Buffer.from(JSON.stringify({ payload, exp: Math.floor(Date.now() / 1000) + ttl })));
|
|
23
|
+
const mac = b64url(createHmac('sha256', secret).update(body).digest());
|
|
24
|
+
const token = `${body}.${mac}`;
|
|
25
|
+
console.log(`${origin.replace(/\/$/, '')}/edit/enroll?invite=${encodeURIComponent(token)}`);
|
|
26
|
+
console.error(`invite for ${payload.name} <${payload.email}>, valid ${days} day(s), single use`);
|
package/index.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// pagestack — a git-only visual section editor that ships inside an Astro site.
|
|
2
|
+
//
|
|
3
|
+
// import pagestack from '@pirateandfox/pagestack';
|
|
4
|
+
// integrations: [sectionEditor({ renderer: './src/components/SectionPage.astro', sections: './src/sections/index.ts' })]
|
|
5
|
+
//
|
|
6
|
+
// The public site stays fully static. The editor (/edit/*, /api/editor/*) is on-demand rendered
|
|
7
|
+
// and only exists when the integration is enabled: always in `astro dev`, and in `astro build`
|
|
8
|
+
// when EDITOR=1 (which produces a Node server build with @astrojs/node).
|
|
9
|
+
import node from '@astrojs/node';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
const VIRTUAL = 'virtual:pagestack/site';
|
|
14
|
+
const VIRTUAL_RENDERER = 'virtual:pagestack/renderer';
|
|
15
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} SectionEditorOptions
|
|
20
|
+
* @property {string} renderer Astro component rendering a page: props { page, editing }.
|
|
21
|
+
* @property {string} sections Module exporting { schemas, presets } (see runtime.ts types).
|
|
22
|
+
* @property {string} [pagesDir='src/content/pages'] Directory of <slug>.json pages.
|
|
23
|
+
* @property {string} [imagesDir='public/images'] Browsable image library root.
|
|
24
|
+
* @property {string} [uploadsDir='public/images/uploads'] Where uploads are written.
|
|
25
|
+
* @property {string} [publicDir='public'] Prefix stripped to turn file paths into URLs.
|
|
26
|
+
* @property {string} [usersFile='editor-users.json'] Passkey users (public keys only).
|
|
27
|
+
* @property {'none'|'passkey'|'none-behind-proxy'} [auth='passkey']
|
|
28
|
+
* @property {'local'|'github'} [storage] Defaults: local in dev, github in a build.
|
|
29
|
+
* @property {{repo: string, branch?: string, pullRequest?: boolean}} [github]
|
|
30
|
+
* @property {boolean} [enabled] Default: dev, or EDITOR=1.
|
|
31
|
+
* @property {string} [siteName] Shown in the editor chrome.
|
|
32
|
+
* @property {string} [interactiveSelector='summary'] Elements in the preview that keep their own click
|
|
33
|
+
* behaviour (accordion summaries, tab titles, load-map buttons…). Everything else is
|
|
34
|
+
* intercepted so links don't navigate while editing.
|
|
35
|
+
* @property {string} [secretEnv='EDITOR_SECRET'] Name of the env var holding the signing secret.
|
|
36
|
+
*
|
|
37
|
+
* Runtime environment (never in the repo):
|
|
38
|
+
* EDITOR_SECRET >= 16 chars; signs sessions, WebAuthn challenges and invite links.
|
|
39
|
+
* EDITOR_ORIGIN Public origin (https://example.com) when the server sits behind a proxy
|
|
40
|
+
* that rewrites Host — passkeys are bound to the origin the browser sees.
|
|
41
|
+
* GITHUB_TOKEN Fine-grained token with contents (+ pull requests) on the repo, github storage only.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** @param {SectionEditorOptions} userOptions */
|
|
45
|
+
export default function sectionEditor(userOptions) {
|
|
46
|
+
if (!userOptions?.renderer || !userOptions?.sections) throw new Error('pagestack: `renderer` and `sections` are required');
|
|
47
|
+
// When the site has no adapter of its own we bring @astrojs/node along and forward every
|
|
48
|
+
// integration hook to it (an integration added via updateConfig() doesn't get to call
|
|
49
|
+
// setAdapter() in time, so we proxy its hooks instead).
|
|
50
|
+
let adapter = null;
|
|
51
|
+
const forward = (hook) => async (params) => { if (adapter?.hooks?.[hook]) await adapter.hooks[hook](params); };
|
|
52
|
+
const forwarded = Object.fromEntries(['astro:config:done', 'astro:server:setup', 'astro:server:start', 'astro:server:done', 'astro:build:start', 'astro:build:setup', 'astro:build:ssr', 'astro:build:generated', 'astro:build:done'].map((h) => [h, forward(h)]));
|
|
53
|
+
return {
|
|
54
|
+
name: 'pagestack',
|
|
55
|
+
hooks: {
|
|
56
|
+
...forwarded,
|
|
57
|
+
'astro:config:setup': async (params) => {
|
|
58
|
+
const { config, command, injectRoute, updateConfig, addMiddleware, logger } = params;
|
|
59
|
+
const enabled = userOptions.enabled ?? (command === 'dev' || process.env.EDITOR === '1');
|
|
60
|
+
if (!enabled) { logger.info('disabled (static build). Set EDITOR=1 to build the editor.'); return; }
|
|
61
|
+
const isBuild = command === 'build';
|
|
62
|
+
const options = {
|
|
63
|
+
pagesDir: 'src/content/pages',
|
|
64
|
+
imagesDir: 'public/images',
|
|
65
|
+
uploadsDir: 'public/images/uploads',
|
|
66
|
+
publicDir: 'public',
|
|
67
|
+
usersFile: 'editor-users.json',
|
|
68
|
+
auth: 'passkey',
|
|
69
|
+
storage: isBuild ? 'github' : 'local',
|
|
70
|
+
siteName: 'Site editor',
|
|
71
|
+
secretEnv: 'EDITOR_SECRET',
|
|
72
|
+
interactiveSelector: 'summary',
|
|
73
|
+
...userOptions,
|
|
74
|
+
github: userOptions.github ?? null,
|
|
75
|
+
};
|
|
76
|
+
if (isBuild && options.auth === 'none') {
|
|
77
|
+
throw new Error('pagestack: auth: "none" is only allowed in `astro dev`. Use "passkey" or "none-behind-proxy" for a build.');
|
|
78
|
+
}
|
|
79
|
+
if (options.storage === 'github' && !options.github?.repo) {
|
|
80
|
+
throw new Error('pagestack: storage "github" needs github.repo ("owner/name")');
|
|
81
|
+
}
|
|
82
|
+
const root = fileURLToPath(config.root);
|
|
83
|
+
const abs = (p) => path.isAbsolute(p) ? p : path.resolve(root, p);
|
|
84
|
+
const serial = { ...options, root, renderer: abs(options.renderer), sections: abs(options.sections), command };
|
|
85
|
+
|
|
86
|
+
updateConfig({
|
|
87
|
+
vite: {
|
|
88
|
+
plugins: [{
|
|
89
|
+
name: 'pagestack:virtual',
|
|
90
|
+
resolveId(id) { if (id === VIRTUAL || id === VIRTUAL_RENDERER) return '\0' + id; },
|
|
91
|
+
load(id) {
|
|
92
|
+
// The renderer (and the site layout/CSS behind it) is only imported by the preview
|
|
93
|
+
// route, so the editor shell doesn't drag the whole site's CSS into its page.
|
|
94
|
+
if (id === '\0' + VIRTUAL_RENDERER) return `export { default as Renderer } from ${JSON.stringify(serial.renderer)};`;
|
|
95
|
+
if (id !== '\0' + VIRTUAL) return;
|
|
96
|
+
return [
|
|
97
|
+
`export * as site from ${JSON.stringify(serial.sections)};`,
|
|
98
|
+
`export const options = ${JSON.stringify(serial)};`,
|
|
99
|
+
].join('\n');
|
|
100
|
+
},
|
|
101
|
+
}],
|
|
102
|
+
// The package is consumed from source (.ts/.astro); make sure Vite transforms it.
|
|
103
|
+
ssr: { noExternal: ['@pirateandfox/pagestack'] },
|
|
104
|
+
// Pre-bundle the editor's browser deps so dev doesn't re-optimise (and reload) mid-session.
|
|
105
|
+
optimizeDeps: { include: ['sortablejs', '@simplewebauthn/browser'] },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
if (!config.adapter) {
|
|
109
|
+
adapter = node({ mode: 'standalone' });
|
|
110
|
+
await adapter.hooks['astro:config:setup']?.(params);
|
|
111
|
+
// Astro's build checks `config.adapter`; integrations added this late aren't hooked
|
|
112
|
+
// up automatically, hence the forwarded hooks above.
|
|
113
|
+
updateConfig({ adapter });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
addMiddleware({ order: 'pre', entrypoint: new URL('./src/server/middleware.ts', import.meta.url) });
|
|
117
|
+
|
|
118
|
+
injectRoute({ pattern: '/edit', entrypoint: route('edit-index.astro'), prerender: false });
|
|
119
|
+
injectRoute({ pattern: '/edit/login', entrypoint: route('login.astro'), prerender: false });
|
|
120
|
+
injectRoute({ pattern: '/edit/enroll', entrypoint: route('enroll.astro'), prerender: false });
|
|
121
|
+
injectRoute({ pattern: '/edit/preview/[...slug]', entrypoint: route('preview.astro'), prerender: false });
|
|
122
|
+
injectRoute({ pattern: '/edit/[...slug]', entrypoint: route('edit-page.astro'), prerender: false });
|
|
123
|
+
injectRoute({ pattern: '/api/editor/pages', entrypoint: route('api/pages.ts'), prerender: false });
|
|
124
|
+
injectRoute({ pattern: '/api/editor/page/[...slug]', entrypoint: route('api/page.ts'), prerender: false });
|
|
125
|
+
injectRoute({ pattern: '/api/editor/images', entrypoint: route('api/images.ts'), prerender: false });
|
|
126
|
+
injectRoute({ pattern: '/api/editor/asset/[...path]', entrypoint: route('api/asset.ts'), prerender: false });
|
|
127
|
+
injectRoute({ pattern: '/api/editor/auth/[action]', entrypoint: route('api/auth.ts'), prerender: false });
|
|
128
|
+
logger.info(`enabled — storage: ${options.storage}, auth: ${options.auth}. Open /edit`);
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pirateandfox/pagestack",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.4",
|
|
4
|
+
"description": "PageStack: visual section editor for Astro sites. Git-only, static public site, passkey login, saves are commits.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pagestack-invite": "bin/invite.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.mjs",
|
|
12
|
+
"./runtime": "./src/runtime.ts",
|
|
13
|
+
"./core/*": "./src/core/*.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.mjs",
|
|
17
|
+
"bin",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"astro": ">=5.0.0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@astrojs/node": "10.1.1",
|
|
28
|
+
"@simplewebauthn/browser": "^13.0.0",
|
|
29
|
+
"@simplewebauthn/server": "^13.0.0",
|
|
30
|
+
"sortablejs": "^1.15.7"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/sortablejs": "^1.15.8",
|
|
34
|
+
"happy-dom": "^21.0.4",
|
|
35
|
+
"vitest": "^4.0.0"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"astro-integration",
|
|
39
|
+
"astro-component",
|
|
40
|
+
"withastro",
|
|
41
|
+
"visual-editor",
|
|
42
|
+
"cms",
|
|
43
|
+
"git-based-cms",
|
|
44
|
+
"sections"
|
|
45
|
+
],
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/pirateandfox/pagestack.git"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/pirateandfox/pagestack#readme",
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Browser side of passkey login / enrolment (login.astro, enroll.astro).
|
|
2
|
+
import { browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser';
|
|
3
|
+
|
|
4
|
+
const root = document.querySelector<HTMLElement>('[data-ase-auth]')!;
|
|
5
|
+
const go = document.getElementById('ase-auth-go') as HTMLButtonElement | null;
|
|
6
|
+
const errEl = document.getElementById('ase-auth-error')!;
|
|
7
|
+
const fail = (msg: string) => { errEl.textContent = msg; errEl.hidden = false; if (go) go.disabled = false; };
|
|
8
|
+
const post = async (action: string, body: unknown) => {
|
|
9
|
+
const res = await fetch(`/api/editor/auth/${action}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
10
|
+
const data = await res.json().catch(() => ({}));
|
|
11
|
+
if (!res.ok) throw new Error(data.error || `${action} failed (${res.status})`);
|
|
12
|
+
return data;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
if (!browserSupportsWebAuthn()) fail('This browser does not support passkeys.');
|
|
16
|
+
else if (go) go.addEventListener('click', async () => {
|
|
17
|
+
go.disabled = true; errEl.hidden = true;
|
|
18
|
+
try {
|
|
19
|
+
if (root.dataset.aseAuth === 'login') {
|
|
20
|
+
const { options, token } = await post('login-options', {});
|
|
21
|
+
const response = await startAuthentication({ optionsJSON: options });
|
|
22
|
+
const { next } = await post('login-verify', { token, response, next: root.dataset.next });
|
|
23
|
+
location.assign(next || '/edit');
|
|
24
|
+
} else {
|
|
25
|
+
const { options, token } = await post('register-options', { invite: root.dataset.invite });
|
|
26
|
+
const response = await startRegistration({ optionsJSON: options });
|
|
27
|
+
const label = (document.getElementById('ase-auth-label') as HTMLInputElement | null)?.value;
|
|
28
|
+
await post('register-verify', { token, response, label });
|
|
29
|
+
location.assign('/edit');
|
|
30
|
+
}
|
|
31
|
+
} catch (e: any) {
|
|
32
|
+
fail(e?.name === 'NotAllowedError' ? 'Cancelled, or no passkey was offered. Try again.' : e?.message || String(e));
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
root.dataset.ready = '1'; // tests wait for this before clicking
|