@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.
- package/README.md +317 -2
- package/bin/invite.mjs +26 -0
- package/index.mjs +159 -0
- package/package.json +57 -4
- package/src/client/auth.ts +35 -0
- package/src/client/collection.ts +37 -0
- package/src/client/editor.ts +966 -0
- package/src/client/form.ts +199 -0
- package/src/client/global.ts +33 -0
- package/src/client/icons.ts +27 -0
- package/src/client/inline.ts +107 -0
- package/src/client/inserter.ts +59 -0
- package/src/client/invite.ts +17 -0
- package/src/client/item.ts +107 -0
- package/src/client/media.ts +81 -0
- package/src/client/pages.ts +89 -0
- package/src/client/picker.ts +109 -0
- package/src/client/preview.css +64 -0
- package/src/client/rich.ts +232 -0
- package/src/client/shell.css +305 -0
- package/src/client/state.ts +90 -0
- package/src/client/tree.ts +69 -0
- package/src/client/ui.ts +122 -0
- package/src/client/upload.ts +25 -0
- package/src/client/users.ts +40 -0
- package/src/core/auth.ts +86 -0
- package/src/core/frontmatter.ts +29 -0
- package/src/core/insert.ts +70 -0
- package/src/core/media.ts +37 -0
- package/src/core/pages.ts +52 -0
- package/src/core/patch.ts +127 -0
- package/src/core/ratelimit.ts +13 -0
- package/src/core/sanitize.ts +268 -0
- package/src/core/schema.ts +111 -0
- package/src/core/storage-github.ts +144 -0
- package/src/core/storage-local.ts +61 -0
- package/src/core/storage.ts +71 -0
- package/src/core/synced.ts +19 -0
- package/src/core/transform.ts +66 -0
- package/src/core/users.ts +111 -0
- package/src/routes/Nav.astro +22 -0
- package/src/routes/api/asset.ts +10 -0
- package/src/routes/api/auth.ts +56 -0
- package/src/routes/api/collection.ts +85 -0
- package/src/routes/api/global.ts +33 -0
- package/src/routes/api/images.ts +65 -0
- package/src/routes/api/page.ts +78 -0
- package/src/routes/api/pages.ts +41 -0
- package/src/routes/api/patterns.ts +68 -0
- package/src/routes/api/users.ts +46 -0
- package/src/routes/edit-collection.astro +47 -0
- package/src/routes/edit-global.astro +39 -0
- package/src/routes/edit-index.astro +60 -0
- package/src/routes/edit-item.astro +57 -0
- package/src/routes/edit-media.astro +30 -0
- package/src/routes/edit-page.astro +70 -0
- package/src/routes/edit-users.astro +43 -0
- package/src/routes/enroll.astro +46 -0
- package/src/routes/login.astro +32 -0
- package/src/routes/preview.astro +34 -0
- package/src/runtime.ts +173 -0
- package/src/server/context.ts +182 -0
- package/src/server/middleware.ts +101 -0
- package/src/server/webauthn.ts +91 -0
- package/src/virtual.d.ts +12 -0
package/README.md
CHANGED
|
@@ -1,3 +1,318 @@
|
|
|
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
|
+
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
});
|