@pirateandfox/pagestack 0.0.0-stage → 0.1.0-beta.5

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 (65) hide show
  1. package/README.md +317 -2
  2. package/bin/invite.mjs +26 -0
  3. package/index.mjs +159 -0
  4. package/package.json +57 -4
  5. package/src/client/auth.ts +35 -0
  6. package/src/client/collection.ts +37 -0
  7. package/src/client/editor.ts +966 -0
  8. package/src/client/form.ts +199 -0
  9. package/src/client/global.ts +33 -0
  10. package/src/client/icons.ts +27 -0
  11. package/src/client/inline.ts +107 -0
  12. package/src/client/inserter.ts +59 -0
  13. package/src/client/invite.ts +17 -0
  14. package/src/client/item.ts +107 -0
  15. package/src/client/media.ts +81 -0
  16. package/src/client/pages.ts +89 -0
  17. package/src/client/picker.ts +109 -0
  18. package/src/client/preview.css +64 -0
  19. package/src/client/rich.ts +232 -0
  20. package/src/client/shell.css +305 -0
  21. package/src/client/state.ts +90 -0
  22. package/src/client/tree.ts +69 -0
  23. package/src/client/ui.ts +122 -0
  24. package/src/client/upload.ts +25 -0
  25. package/src/client/users.ts +40 -0
  26. package/src/core/auth.ts +86 -0
  27. package/src/core/frontmatter.ts +29 -0
  28. package/src/core/insert.ts +70 -0
  29. package/src/core/media.ts +37 -0
  30. package/src/core/pages.ts +52 -0
  31. package/src/core/patch.ts +127 -0
  32. package/src/core/ratelimit.ts +13 -0
  33. package/src/core/sanitize.ts +268 -0
  34. package/src/core/schema.ts +111 -0
  35. package/src/core/storage-github.ts +144 -0
  36. package/src/core/storage-local.ts +61 -0
  37. package/src/core/storage.ts +71 -0
  38. package/src/core/synced.ts +19 -0
  39. package/src/core/transform.ts +66 -0
  40. package/src/core/users.ts +111 -0
  41. package/src/routes/Nav.astro +22 -0
  42. package/src/routes/api/asset.ts +10 -0
  43. package/src/routes/api/auth.ts +56 -0
  44. package/src/routes/api/collection.ts +85 -0
  45. package/src/routes/api/global.ts +33 -0
  46. package/src/routes/api/images.ts +65 -0
  47. package/src/routes/api/page.ts +78 -0
  48. package/src/routes/api/pages.ts +41 -0
  49. package/src/routes/api/patterns.ts +68 -0
  50. package/src/routes/api/users.ts +46 -0
  51. package/src/routes/edit-collection.astro +47 -0
  52. package/src/routes/edit-global.astro +39 -0
  53. package/src/routes/edit-index.astro +60 -0
  54. package/src/routes/edit-item.astro +57 -0
  55. package/src/routes/edit-media.astro +30 -0
  56. package/src/routes/edit-page.astro +70 -0
  57. package/src/routes/edit-users.astro +43 -0
  58. package/src/routes/enroll.astro +46 -0
  59. package/src/routes/login.astro +32 -0
  60. package/src/routes/preview.astro +34 -0
  61. package/src/runtime.ts +173 -0
  62. package/src/server/context.ts +182 -0
  63. package/src/server/middleware.ts +101 -0
  64. package/src/server/webauthn.ts +91 -0
  65. package/src/virtual.d.ts +12 -0
package/README.md CHANGED
@@ -1,3 +1,318 @@
1
- # Temporary Holding Version
1
+ # PageStack
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ reservedSlugs: blogSlugs, // URLs the site uses some other way
52
+ auth: 'passkey', // 'passkey' | 'none-behind-proxy' | 'none' (dev only)
53
+ storage: process.env.EDITOR === '1' ? 'github' : 'local',
54
+ github: { repo: 'acme/site', branch: 'main', pullRequest: true },
55
+ }),
56
+ ],
57
+ });
58
+ ```
59
+
60
+ ```bash
61
+ npm run dev # editor at http://localhost:4321/edit, saves to the working tree
62
+ EDITOR=1 astro build # dist/server (node) + dist/client — the editor for a host
63
+ astro build # plain static site, no editor code
64
+ ```
65
+
66
+ ## What the site provides
67
+
68
+ ### 1. A content model: `src/content/pages/<slug>.json`
69
+
70
+ ```jsonc
71
+ {
72
+ "title": "Home",
73
+ "description": "…",
74
+ "layout": { "footer": "big", "css": ["post-9.css"] }, // opaque to the editor
75
+ "sections": [
76
+ { "id": "c8da6f4", "type": "container", "settings": { "backgroundColor": "#fff" },
77
+ "blocks": [
78
+ { "id": "1a2b3c4", "type": "heading", "settings": { "text": "Hello", "tag": "h1" } },
79
+ { "id": "5d6e7f8", "type": "text", "settings": { "html": "<p>…</p>" } }
80
+ ] }
81
+ ]
82
+ }
83
+ ```
84
+
85
+ A page is a tree of nodes. Top-level nodes are *sections*; anything can nest (`blocks`).
86
+ `attrs` is a passthrough for element attributes the editor never shows (class, aria-*).
87
+ `"status": "draft"` keeps a page out of the build; without it the page is published.
88
+
89
+ To let editors add pages, render them from one catch-all route. A new JSON file is then a new page,
90
+ and drafts are left out:
91
+
92
+ ```astro
93
+ ---
94
+ // src/pages/[...page].astro
95
+ import { staticPages } from '@pirateandfox/pagestack/runtime';
96
+ import SectionPage from '../components/SectionPage.astro';
97
+ export const getStaticPaths = () =>
98
+ staticPages(import.meta.glob('../content/pages/**/*.json', { eager: true }));
99
+ const { page } = Astro.props;
100
+ ---
101
+ <SectionPage page={page} />
102
+ ```
103
+
104
+ A site with its own `.astro` file per page keeps working, but pages added in the editor have no
105
+ route until the catch-all exists.
106
+
107
+ ### 2. A block registry: `src/sections/index.ts`
108
+
109
+ ```ts
110
+ import type { BlockSchema, Preset } from '@pirateandfox/pagestack/runtime';
111
+ export const schemas = {
112
+ heading: {
113
+ label: 'Heading',
114
+ fields: {
115
+ text: { type: 'text', label: 'Text', inline: true, default: 'Heading' },
116
+ tag: { type: 'select', label: 'HTML tag', options: [...], default: 'h2', group: 'advanced' },
117
+ color: { type: 'color', label: 'Text colour', group: 'style' },
118
+ },
119
+ component: Heading, // the Astro component
120
+ },
121
+ container: { label: 'Container', fields: {...}, blocks: '*', component: Container },
122
+ gallery: { label: 'Gallery', fields: {}, blocks: ['gallery-item'], component: Gallery },
123
+ 'gallery-item': { label: 'Image', fields: {...}, hidden: true, component: GalleryItem },
124
+ } satisfies Record<string, BlockSchema & { component: any }>;
125
+
126
+ export const presets: Preset[] = [
127
+ { label: 'Hero', description: 'Full-width image with a heading and two buttons', create: () => ({ ... }) },
128
+ ];
129
+ ```
130
+
131
+ Field types: `text`, `richtext`, `image`, `link`, `color`, `select`, `number`, `boolean`,
132
+ `textarea`, `code`, `svg`. `inline: true` on `text` / `richtext` / `image` makes the field
133
+ editable by clicking it in the preview. The schema drives the sidebar form, validation on save,
134
+ the add-block menus and the tree labels. **Presets are the section library.**
135
+
136
+ ### 3. Components that mark what they render
137
+
138
+ Each block component receives `{ node, edit }` and spreads two helpers onto its markup:
139
+
140
+ ```astro
141
+ ---
142
+ import { nodeAttrs, fieldAttrs } from '@pirateandfox/pagestack/runtime';
143
+ const { node, edit } = Astro.props;
144
+ ---
145
+ <div {...nodeAttrs(edit, node)}>
146
+ <h2 {...fieldAttrs(edit, 'text', 'text')} set:html={node.settings.text} />
147
+ </div>
148
+ ```
149
+
150
+ `edit` is `undefined` on the public site, so nothing leaks into the static output. A container
151
+ passes `child(edit, i)` to each child. The `renderer` component takes `{ page, editing }` and
152
+ renders the page inside the site's normal layout.
153
+
154
+ ## Editing
155
+
156
+ Coming from the WordPress block editor:
157
+
158
+ | WordPress | PageStack |
159
+ |---|---|
160
+ | Pages list | `/edit`: search, status, add, copy, delete |
161
+ | Block inserter, Patterns | `+` or `/`: searchable blocks by category, Patterns tab, `+` between sections |
162
+ | List View | Same, with drag to reorder and to move between containers |
163
+ | Block toolbar, ⋮ menu | Move, parent, add, duplicate, delete, copy, save as pattern |
164
+ | Settings sidebar, Page / Block tabs | Same, generated from the schema |
165
+ | Rich text toolbar, links | Same; `⌘B`, `⌘I`, `⌘K`, and an HTML view |
166
+ | Save draft, Publish, Preview | Same; Preview opens the unsaved draft in a new tab |
167
+ | Revisions | The page's git history |
168
+ | Permalink | Change URL, which also adds a redirect |
169
+ | Menus, site options | Global content, at `/edit/global/<id>` |
170
+ | Patterns, synced patterns | Saved patterns; synced ones update every copy in one commit |
171
+ | Block transforms | ⋮ → Turn into… |
172
+ | `/` in an empty block | Same |
173
+ | Posts | `/edit/collection/<id>`: details form, Markdown with live preview |
174
+ | Media Library | `/edit/media` |
175
+ | Users, roles | `/edit/users`: Admin and Editor |
176
+
177
+ - **Preview**: the page renders in a frame at real device widths, scaled to fit. Every change POSTs
178
+ the draft to `/edit/preview/<slug>`; the server renders it and only changed sections are swapped.
179
+ - **Inline**: click text to edit it in place; click an image to open the picker (alt text required).
180
+ - **Drafts** are kept in `localStorage` until saved; undo/redo is per session.
181
+ - **Save** sanitises rich text, validates the document, and writes the file (local) or commits
182
+ (github). A hash of what you loaded is sent along; if someone saved first you get a 409 and a
183
+ reload prompt.
184
+
185
+ Optional exports from the `sections` module:
186
+
187
+ ```ts
188
+ // Fields on the Page tab, stored in page.layout
189
+ export const pageFields = {
190
+ footer: { type: 'select', label: 'Footer', options: [{ value: 'big', label: 'Big' }, { value: 'small', label: 'Small' }] },
191
+ };
192
+
193
+ // Starting points offered by Add page
194
+ export const templates = [{ label: 'Standard page', create: ({ title }) => ({ title, sections: [/* … */] }) }];
195
+
196
+ // JSON files edited at /edit/global/<id>
197
+ // Markdown folders edited at /edit/collection/<id>
198
+ export const collections = {
199
+ blog: {
200
+ label: 'Posts', singular: 'post', dir: 'src/content/blog', url: (slug) => `/${slug}/`,
201
+ fields: { title: { type: 'text', label: 'Title' }, draft: { type: 'boolean', label: 'Draft' }, date: { type: 'date', label: 'Date' }, tags: { type: 'tags', label: 'Tags' } },
202
+ },
203
+ };
204
+
205
+ export const globals = {
206
+ menus: {
207
+ label: 'Menus',
208
+ file: 'src/data/menus.json',
209
+ fields: {
210
+ nav: { type: 'list', label: 'Main menu', itemLabel: 'label', fields: { label: { type: 'text', label: 'Label' } } },
211
+ },
212
+ },
213
+ };
214
+ ```
215
+
216
+ Blocks can set `category` and `keywords` for the inserter. A `list` field is a repeatable group of
217
+ `fields`, with `itemLabel` naming the field that titles each row.
218
+
219
+ ## Security
220
+
221
+ Everything an editor sends is treated as untrusted. On save, every field is cleaned by its type,
222
+ and anything outside the schema is cleaned as HTML, so components can render fields with
223
+ `set:html`. Only admins can put raw HTML in a `code` field. Uploads are checked by their bytes and
224
+ SVGs are sanitised. Set `EDITOR_ORIGIN` to the public https origin in production.
225
+
226
+ With `auth: "none-behind-proxy"`, set `EDITOR_PROXY_SECRET` if the app can be reached without going
227
+ through the proxy: requests must then carry the same value in `x-editor-proxy-secret`. Otherwise the
228
+ identity headers could be forged by anyone who reaches the app directly.
229
+
230
+ The login rate limit is held in memory per server instance, so on Workers it slows guessing rather
231
+ than setting a hard limit. Passkeys are what actually stops guessing.
232
+
233
+ ## Storage
234
+
235
+ | `storage` | Reads | Saves | Uploads |
236
+ |---|---|---|---|
237
+ | `local` (dev default) | working tree | writes the JSON file (you commit) | written to `public/images/uploads/` |
238
+ | `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 | wait on a `pagestack-uploads` branch, shown to signed-in editors, until the next save brings them along |
239
+
240
+ The GitHub token (`GITHUB_TOKEN` or `EDITOR_GITHUB_TOKEN`) is read from the environment on the
241
+ server only. A fine-grained token with *Contents: write* (and *Pull requests: write* for PR mode)
242
+ on the one repo is enough. The host that runs the editor rebuilds and deploys from the commit
243
+ like any other push.
244
+
245
+ ## Login
246
+
247
+ ### `auth: "passkey"` (default)
248
+
249
+ - `editor-users.json` (committed, path configurable with `usersFile`) holds users and the
250
+ **public keys** of their passkeys. Nothing in it is secret.
251
+ - `EDITOR_SECRET` (≥ 16 chars, environment only) signs three kinds of short-lived tokens:
252
+ WebAuthn challenges (5 min), the session cookie (`ase_session`, HttpOnly, SameSite=Lax, 12 h) and
253
+ invite links (3 days). Tokens are `base64url(json).base64url(hmac-sha256)` with the expiry inside,
254
+ so there is no server state.
255
+ - **Invites** are single-use: the invite id is recorded on the credential it created. Make the first
256
+ one from the CLI, later ones from the "Invite another editor" panel on `/edit`:
257
+
258
+ ```bash
259
+ EDITOR_SECRET=… npx pagestack-invite --name "Jane Doe" --email jane@example.com --origin https://example.com
260
+ ```
261
+ - `/edit/enroll?invite=…` creates the passkey (Touch ID / Face ID / Windows Hello / security key);
262
+ `/edit/login` signs in with it. Several passkeys per person are fine (enrol again with a new invite).
263
+ - Set `EDITOR_ORIGIN` when a proxy rewrites `Host`; WebAuthn binds credentials to the origin the
264
+ browser sees.
265
+ - The signature counter is not persisted (it would mean a commit per login; synced passkeys report 0).
266
+
267
+ ### `auth: "none-behind-proxy"`
268
+
269
+ Use when something in front of the editor already authenticates people, e.g. **Cloudflare Access**:
270
+ put the editor host behind an Access application for `/edit/*` and `/api/editor/*`, allow the
271
+ client's email addresses, and the editor trusts the proxy. It reads the identity from
272
+ `Cf-Access-Authenticated-User-Email` (or `X-Forwarded-User`) for commit attribution. This is a
273
+ good fit when the site already sits on Cloudflare and the client prefers email one-time codes over
274
+ passkeys. (A production deployment should also verify the Access JWT on the editor host; that's not
275
+ implemented here.)
276
+
277
+ ### `auth: "none"`
278
+
279
+ Dev only. A build with `auth: "none"` is refused.
280
+
281
+ ## Deploying the editor
282
+
283
+ Requires Node 22.23.4+ and Astro 7.2.1+. On Cloudflare, add `@astrojs/cloudflare` as the host's
284
+ adapter and set `EDITOR_SECRET`, `EDITOR_ORIGIN` and `GITHUB_TOKEN` as secrets.
285
+
286
+ The editor server is a normal `@astrojs/node` standalone build. Run `node dist/server/entry.mjs`
287
+ on any small Node host (Fly, Railway, a VPS…) with `EDITOR_SECRET`, `GITHUB_TOKEN` and
288
+ `EDITOR_ORIGIN` set, behind HTTPS. It serves the editor routes and the static pages it was built
289
+ with; the public site is still deployed separately from the same repo as static files. Every
290
+ editor response is `noindex` and `no-store`.
291
+
292
+ ## Options
293
+
294
+ | option | default | |
295
+ |---|---|---|
296
+ | `renderer` | required | Astro component rendering `{ page, editing }` |
297
+ | `sections` | required | module exporting `{ schemas, presets }` |
298
+ | `pagesDir` | `src/content/pages` | |
299
+ | `imagesDir` / `uploadsDir` / `publicDir` | `public/images` / `public/images/uploads` / `public` | |
300
+ | `usersFile` | `editor-users.json` | |
301
+ | `auth` | `passkey` | `passkey` \| `none-behind-proxy` \| `none` |
302
+ | `storage` | `local` in dev, `github` in build | |
303
+ | `github` | — | `{ repo, branch, pullRequest }` |
304
+ | `enabled` | dev, or `EDITOR=1` | |
305
+ | `siteName`, `secretEnv` | `Site editor`, `EDITOR_SECRET` | |
306
+ | `reservedSlugs` | `[]` | URLs Add page must refuse, on top of the editor's own and `src/pages` |
307
+ | `redirectsFile` | `public/_redirects` | Change URL appends here, only if the file exists |
308
+ | `patternsFile` | `src/content/pagestack-patterns.json` | where saved patterns live |
309
+ | `clientIpHeader` | none | header with the client IP behind a proxy, for login rate limits, e.g. `cf-connecting-ip` |
310
+
311
+ ## Open-source pieces used
312
+
313
+ - [SortableJS](https://github.com/SortableJS/Sortable) (MIT) for the tree drag-and-drop.
314
+ - [@simplewebauthn/server + browser](https://simplewebauthn.dev) (MIT) for WebAuthn.
315
+ - [@astrojs/node](https://docs.astro.build/en/guides/integrations-guide/node/) (MIT) as the editor's adapter.
316
+ - [Puck](https://puckeditor.com) was used as a design reference for JSON page editing only; no code.
317
+
318
+ 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 are made at /edit/users; CLI invites create admins).
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,159 @@
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 fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ const VIRTUAL = 'virtual:pagestack/site';
15
+ const VIRTUAL_RENDERER = 'virtual:pagestack/renderer';
16
+ const here = path.dirname(fileURLToPath(import.meta.url));
17
+ const route = (file) => new URL(`./src/routes/${file}`, import.meta.url);
18
+
19
+ /**
20
+ * @typedef {object} SectionEditorOptions
21
+ * @property {string} renderer Astro component rendering a page: props { page, editing }.
22
+ * @property {string} sections Module exporting { schemas, presets } (see runtime.ts types).
23
+ * @property {string} [pagesDir='src/content/pages'] Directory of <slug>.json pages.
24
+ * @property {string} [imagesDir='public/images'] Browsable image library root.
25
+ * @property {string} [uploadsDir='public/images/uploads'] Where uploads are written.
26
+ * @property {string} [publicDir='public'] Prefix stripped to turn file paths into URLs.
27
+ * @property {string} [usersFile='editor-users.json'] Passkey users (public keys only).
28
+ * @property {'none'|'passkey'|'none-behind-proxy'} [auth='passkey']
29
+ * @property {'local'|'github'} [storage] Defaults: local in dev, github in a build.
30
+ * @property {{repo: string, branch?: string, pullRequest?: boolean}} [github]
31
+ * @property {boolean} [enabled] Default: dev, or EDITOR=1.
32
+ * @property {string} [siteName] Shown in the editor chrome.
33
+ * @property {string} [interactiveSelector='summary'] Elements in the preview that keep their own click
34
+ * behaviour (accordion summaries, tab titles, load-map buttons…). Everything else is
35
+ * intercepted so links don't navigate while editing.
36
+ * @property {string} [secretEnv='EDITOR_SECRET'] Name of the env var holding the signing secret.
37
+ * @property {string[]} [reservedSlugs] Top-level URLs new pages may not take, beyond the host's own files in
38
+ * src/pages, which are found automatically. For dynamic routes such as blog posts.
39
+ * @property {string} [redirectsFile='public/_redirects'] When this file exists, moving a page appends a 301 to it.
40
+ * @property {string} [clientIpHeader] Header holding the client IP behind a proxy, for login rate limits (e.g. cf-connecting-ip).
41
+ * @property {string} [patternsFile='src/content/pagestack-patterns.json'] Where saved patterns are kept.
42
+ *
43
+ * Runtime environment (never in the repo):
44
+ * EDITOR_SECRET >= 16 chars; signs sessions, WebAuthn challenges and invite links.
45
+ * EDITOR_ORIGIN Public origin (https://example.com) when the server sits behind a proxy
46
+ * that rewrites Host — passkeys are bound to the origin the browser sees.
47
+ * GITHUB_TOKEN Fine-grained token with contents (+ pull requests) on the repo, github storage only.
48
+ */
49
+
50
+ /** Top-level URLs taken by the host's own route files (dynamic ones can't be known here). */
51
+ function staticRoutes(srcDir) {
52
+ try {
53
+ return fs.readdirSync(path.join(srcDir, 'pages'), { withFileTypes: true })
54
+ .filter((e) => !/^[[_.]/.test(e.name) && e.name !== 'index.astro' && (e.isDirectory() || /\.(astro|md|mdx|html|js|ts)$/.test(e.name)))
55
+ .map((e) => e.name.replace(/\.[^.]+$/, ''));
56
+ } catch { return []; }
57
+ }
58
+
59
+ /** @param {SectionEditorOptions} userOptions */
60
+ export default function sectionEditor(userOptions) {
61
+ if (!userOptions?.renderer || !userOptions?.sections) throw new Error('pagestack: `renderer` and `sections` are required');
62
+ // When the site has no adapter of its own we bring @astrojs/node along and forward every
63
+ // integration hook to it (an integration added via updateConfig() doesn't get to call
64
+ // setAdapter() in time, so we proxy its hooks instead).
65
+ let adapter = null;
66
+ const forward = (hook) => async (params) => { if (adapter?.hooks?.[hook]) await adapter.hooks[hook](params); };
67
+ 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)]));
68
+ return {
69
+ name: 'pagestack',
70
+ hooks: {
71
+ ...forwarded,
72
+ 'astro:config:setup': async (params) => {
73
+ const { config, command, injectRoute, updateConfig, addMiddleware, logger } = params;
74
+ const enabled = userOptions.enabled ?? (command === 'dev' || process.env.EDITOR === '1');
75
+ if (!enabled) { logger.info('disabled (static build). Set EDITOR=1 to build the editor.'); return; }
76
+ const isBuild = command === 'build';
77
+ const options = {
78
+ pagesDir: 'src/content/pages',
79
+ imagesDir: 'public/images',
80
+ uploadsDir: 'public/images/uploads',
81
+ publicDir: 'public',
82
+ usersFile: 'editor-users.json',
83
+ auth: 'passkey',
84
+ storage: isBuild ? 'github' : 'local',
85
+ siteName: 'Site editor',
86
+ secretEnv: 'EDITOR_SECRET',
87
+ interactiveSelector: 'summary',
88
+ redirectsFile: 'public/_redirects',
89
+ patternsFile: 'src/content/pagestack-patterns.json',
90
+ ...userOptions,
91
+ github: userOptions.github ?? null,
92
+ };
93
+ if (isBuild && options.auth === 'none') {
94
+ throw new Error('pagestack: auth: "none" is only allowed in `astro dev`. Use "passkey" or "none-behind-proxy" for a build.');
95
+ }
96
+ if (options.storage === 'github' && !options.github?.repo) {
97
+ throw new Error('pagestack: storage "github" needs github.repo ("owner/name")');
98
+ }
99
+ const root = fileURLToPath(config.root);
100
+ const abs = (p) => path.isAbsolute(p) ? p : path.resolve(root, p);
101
+ const reservedSlugs = [...new Set([...(options.reservedSlugs ?? []), ...staticRoutes(fileURLToPath(config.srcDir))])];
102
+ const serial = { ...options, reservedSlugs, root, renderer: abs(options.renderer), sections: abs(options.sections), command };
103
+
104
+ updateConfig({
105
+ vite: {
106
+ plugins: [{
107
+ name: 'pagestack:virtual',
108
+ resolveId(id) { if (id === VIRTUAL || id === VIRTUAL_RENDERER) return '\0' + id; },
109
+ load(id) {
110
+ // The renderer (and the site layout/CSS behind it) is only imported by the preview
111
+ // route, so the editor shell doesn't drag the whole site's CSS into its page.
112
+ if (id === '\0' + VIRTUAL_RENDERER) return `export { default as Renderer } from ${JSON.stringify(serial.renderer)};`;
113
+ if (id !== '\0' + VIRTUAL) return;
114
+ return [
115
+ `export * as site from ${JSON.stringify(serial.sections)};`,
116
+ `export const options = ${JSON.stringify(serial)};`,
117
+ ].join('\n');
118
+ },
119
+ }],
120
+ // The package is consumed from source (.ts/.astro); make sure Vite transforms it.
121
+ ssr: { noExternal: ['@pirateandfox/pagestack'] },
122
+ // Pre-bundle the editor's browser deps so dev doesn't re-optimise (and reload) mid-session.
123
+ optimizeDeps: { include: ['sortablejs', '@simplewebauthn/browser', 'marked'] },
124
+ },
125
+ });
126
+ if (!config.adapter) {
127
+ adapter = node({ mode: 'standalone' });
128
+ await adapter.hooks['astro:config:setup']?.(params);
129
+ // Astro's build checks `config.adapter`; integrations added this late aren't hooked
130
+ // up automatically, hence the forwarded hooks above.
131
+ updateConfig({ adapter });
132
+ }
133
+
134
+ addMiddleware({ order: 'pre', entrypoint: new URL('./src/server/middleware.ts', import.meta.url) });
135
+
136
+ injectRoute({ pattern: '/edit', entrypoint: route('edit-index.astro'), prerender: false });
137
+ injectRoute({ pattern: '/edit/login', entrypoint: route('login.astro'), prerender: false });
138
+ injectRoute({ pattern: '/edit/enroll', entrypoint: route('enroll.astro'), prerender: false });
139
+ injectRoute({ pattern: '/edit/preview/[...slug]', entrypoint: route('preview.astro'), prerender: false });
140
+ injectRoute({ pattern: '/edit/global/[id]', entrypoint: route('edit-global.astro'), prerender: false });
141
+ injectRoute({ pattern: '/edit/collection/[id]', entrypoint: route('edit-collection.astro'), prerender: false });
142
+ injectRoute({ pattern: '/edit/collection/[id]/[...slug]', entrypoint: route('edit-item.astro'), prerender: false });
143
+ injectRoute({ pattern: '/edit/media', entrypoint: route('edit-media.astro'), prerender: false });
144
+ injectRoute({ pattern: '/edit/users', entrypoint: route('edit-users.astro'), prerender: false });
145
+ injectRoute({ pattern: '/edit/[...slug]', entrypoint: route('edit-page.astro'), prerender: false });
146
+ injectRoute({ pattern: '/api/editor/pages', entrypoint: route('api/pages.ts'), prerender: false });
147
+ injectRoute({ pattern: '/api/editor/page/[...slug]', entrypoint: route('api/page.ts'), prerender: false });
148
+ injectRoute({ pattern: '/api/editor/patterns', entrypoint: route('api/patterns.ts'), prerender: false });
149
+ injectRoute({ pattern: '/api/editor/global/[id]', entrypoint: route('api/global.ts'), prerender: false });
150
+ injectRoute({ pattern: '/api/editor/images', entrypoint: route('api/images.ts'), prerender: false });
151
+ injectRoute({ pattern: '/api/editor/collection/[id]/[...slug]', entrypoint: route('api/collection.ts'), prerender: false });
152
+ injectRoute({ pattern: '/api/editor/users', entrypoint: route('api/users.ts'), prerender: false });
153
+ injectRoute({ pattern: '/api/editor/asset/[...path]', entrypoint: route('api/asset.ts'), prerender: false });
154
+ injectRoute({ pattern: '/api/editor/auth/[action]', entrypoint: route('api/auth.ts'), prerender: false });
155
+ logger.info(`enabled — storage: ${options.storage}, auth: ${options.auth}. Open /edit`);
156
+ },
157
+ },
158
+ };
159
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@pirateandfox/pagestack",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.5",
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": ">=7.2.1"
25
+ },
26
+ "dependencies": {
27
+ "@astrojs/node": "11.1.11",
28
+ "@simplewebauthn/browser": "^13.0.0",
29
+ "@simplewebauthn/server": "^13.0.0",
30
+ "marked": "^18.1.3",
31
+ "sortablejs": "^1.15.7",
32
+ "yaml": "^2.9.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/sortablejs": "^1.15.8",
36
+ "happy-dom": "^21.0.4",
37
+ "vitest": "^4.0.0"
38
+ },
39
+ "keywords": [
40
+ "astro-integration",
41
+ "astro-component",
42
+ "withastro",
43
+ "visual-editor",
44
+ "cms",
45
+ "git-based-cms",
46
+ "sections"
47
+ ],
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/pirateandfox/pagestack.git"
51
+ },
52
+ "homepage": "https://github.com/pirateandfox/pagestack#readme",
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "engines": {
57
+ "node": ">=22.23.4"
58
+ }
59
+ }
@@ -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
@@ -0,0 +1,37 @@
1
+ // The list of a collection: add, delete, search.
2
+ import { slugify, uniqueSlug } from '../core/pages';
3
+ import { confirmDialog, promptDialog, toast } from './ui';
4
+
5
+ const boot = (window as any).__ASE_COLLECTION__ as { id: string; singular: string; slugs: string[]; reserved: string[] };
6
+ const api = `/api/editor/collection/${boot.id}`;
7
+
8
+ document.getElementById('ase-add-item')!.addEventListener('click', async () => {
9
+ const title = await promptDialog({ title: `Add ${boot.singular}`, label: 'Title', submit: `Add ${boot.singular}` });
10
+ if (!title) return;
11
+ const slug = uniqueSlug(slugify(title) || boot.singular, [...boot.slugs, ...boot.reserved]);
12
+ const res = await fetch(api, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title, slug }) });
13
+ const data = await res.json();
14
+ if (!res.ok) { toast(data.error || 'That did not work.', 'error'); return; }
15
+ location.href = `/edit/collection/${boot.id}/${data.slug}`;
16
+ });
17
+
18
+ document.getElementById('ase-pagelist')!.addEventListener('click', async (e) => {
19
+ const b = (e.target as HTMLElement).closest<HTMLElement>('[data-delete]');
20
+ if (!b) return;
21
+ const row = b.closest('li')!;
22
+ const name = row.querySelector('strong')!.textContent;
23
+ if (!await confirmDialog(`Delete ${boot.singular}`, `Delete "${name}"? It stays in the site's git history, so it can be recovered by a developer.`, 'Delete', 'danger')) return;
24
+ const res = await fetch(`${api}/${b.dataset.delete}`, { method: 'DELETE' });
25
+ const data = await res.json();
26
+ if (!res.ok) { toast(data.error || 'Delete failed', 'error'); return; }
27
+ row.remove();
28
+ toast(`Deleted ${name}.`, 'ok');
29
+ });
30
+
31
+ const search = document.getElementById('ase-page-search') as HTMLInputElement;
32
+ search.addEventListener('input', () => {
33
+ const q = search.value.trim().toLowerCase();
34
+ let shown = 0;
35
+ document.querySelectorAll<HTMLElement>('#ase-pagelist > li').forEach((li) => { const on = li.dataset.search!.includes(q); li.hidden = !on; if (on) shown++; });
36
+ document.getElementById('ase-no-match')!.hidden = shown > 0;
37
+ });