uncial-cms 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +618 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.js +15 -0
- package/dist/cli/assert-clean-pages.d.ts +10 -0
- package/dist/cli/assert-clean-pages.js +153 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +3 -0
- package/dist/cli/doctor.d.ts +23 -0
- package/dist/cli/doctor.js +217 -0
- package/dist/cli/run.d.ts +4 -0
- package/dist/cli/run.js +99 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/define-site.d.ts +37 -0
- package/dist/define-site.js +24 -0
- package/dist/deploy-status.d.ts +55 -0
- package/dist/deploy-status.js +118 -0
- package/dist/document.d.ts +6 -0
- package/dist/document.js +23 -0
- package/dist/editor-controller.d.ts +76 -0
- package/dist/editor-controller.js +172 -0
- package/dist/editor-session.d.ts +60 -0
- package/dist/editor-session.js +63 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +14 -0
- package/dist/fit-image.d.ts +31 -0
- package/dist/fit-image.js +88 -0
- package/dist/github/adapter.d.ts +3 -0
- package/dist/github/adapter.js +135 -0
- package/dist/github/index.d.ts +3 -0
- package/dist/github/index.js +3 -0
- package/dist/github/pat.d.ts +7 -0
- package/dist/github/pat.js +35 -0
- package/dist/github/popup.d.ts +9 -0
- package/dist/github/popup.js +75 -0
- package/dist/index-actions.d.ts +74 -0
- package/dist/index-actions.js +147 -0
- package/dist/index-page.d.ts +19 -0
- package/dist/index-page.js +224 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +21 -0
- package/dist/local/adapter.d.ts +2 -0
- package/dist/local/adapter.js +63 -0
- package/dist/local/constants.d.ts +1 -0
- package/dist/local/constants.js +1 -0
- package/dist/local/index.d.ts +4 -0
- package/dist/local/index.js +4 -0
- package/dist/local/session.d.ts +2 -0
- package/dist/local/session.js +12 -0
- package/dist/local/vite.d.ts +8 -0
- package/dist/local/vite.js +243 -0
- package/dist/mount.d.ts +43 -0
- package/dist/mount.js +151 -0
- package/dist/paths/index.d.ts +17 -0
- package/dist/paths/index.js +47 -0
- package/dist/sentinel.d.ts +6 -0
- package/dist/sentinel.js +6 -0
- package/dist/served-url.d.ts +16 -0
- package/dist/served-url.js +19 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +30 -0
- package/dist/svelte/EditorPage.svelte +178 -0
- package/dist/svelte/EditorPage.svelte.d.ts +23 -0
- package/dist/svelte/index.d.ts +5 -0
- package/dist/svelte/index.js +5 -0
- package/dist/svelte/styles.d.ts +4 -0
- package/dist/sveltekit/index.d.ts +68 -0
- package/dist/sveltekit/index.js +98 -0
- package/dist/sveltekit/mapping.d.ts +1 -0
- package/dist/sveltekit/mapping.js +1 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.js +1 -0
- package/dist/upload-context.d.ts +24 -0
- package/dist/upload-context.js +10 -0
- package/dist/vite/index.d.ts +9 -0
- package/dist/vite/index.js +49 -0
- package/package.json +110 -0
package/README.md
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
# uncial-cms
|
|
2
|
+
|
|
3
|
+
Git-forge-backed static CMS runtime for [Uncial](https://github.com/d-flood/uncial).
|
|
4
|
+
Every content page is a JSON document in your site's git repository — the
|
|
5
|
+
repository is the **single source of truth**. The build renders each content
|
|
6
|
+
page *and* a matching editor variant; an editor visits `/about/edit/`, signs in
|
|
7
|
+
with their GitHub account, and edits the page in place with the same components
|
|
8
|
+
and layout. Saving commits the JSON back to the repo, which triggers your
|
|
9
|
+
normal deploy. There is no CMS server, no database, and no user table.
|
|
10
|
+
|
|
11
|
+
**[Live demo →](https://d-flood.github.io/uncial/docs/)** — the Uncial docs are
|
|
12
|
+
a prerendered SvelteKit site managed by uncial-cms, editing this repository
|
|
13
|
+
itself. The docs are the live demo.
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
- A content page `/about/` is backed by `content/about.json` (a normalized
|
|
18
|
+
Uncial document). Nested paths map naturally: `/blog/hello/` ↔
|
|
19
|
+
`content/blog/hello.json`.
|
|
20
|
+
- Production (non-editor) pages ship **zero** uncial-cms JavaScript. The editing
|
|
21
|
+
surface exists only on generated editor variants (`/about/edit/`) and the site
|
|
22
|
+
index (`/uncial/`).
|
|
23
|
+
- **Load:** the runtime fetches the JSON + its blob sha live from the forge
|
|
24
|
+
(never the baked build output), normalizes it, and mounts `<uncial-editor>`.
|
|
25
|
+
- **Save:** validate → serialize → commit with the recorded sha (optimistic
|
|
26
|
+
concurrency) → poll commit status and surface *building… / live*.
|
|
27
|
+
- **Conflict:** a stale sha yields a 409 that surfaces a blocking banner —
|
|
28
|
+
download your version, or reload the latest — never a silent overwrite.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm install uncial uncial-cms
|
|
34
|
+
pnpm add uncial uncial-cms
|
|
35
|
+
bun add uncial uncial-cms
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`uncial` is a peer dependency. `svelte` is a peer of the `uncial-cms/svelte`
|
|
39
|
+
subpath, and `@sveltejs/kit` of `uncial-cms/sveltekit` — the runtime root
|
|
40
|
+
imports neither.
|
|
41
|
+
|
|
42
|
+
The package also ships a `uncial-cms` command; see
|
|
43
|
+
[The `uncial-cms` command](#the-uncial-cms-command).
|
|
44
|
+
|
|
45
|
+
## Exports
|
|
46
|
+
|
|
47
|
+
- `uncial-cms` — `defineSite`, the framework-agnostic browser runtime
|
|
48
|
+
(`mountEditorPage`, `mountIndexPage`), `createPage`/`deletePage`/`listPages`,
|
|
49
|
+
`uploadAsset`/`uploadImageAsset`/`fitImage`/`servedUrl` (see
|
|
50
|
+
[Media](#media)), `parseDocument`/`serializeDocument`, the session providers
|
|
51
|
+
(`popupSessionProvider`, `patSessionProvider`), `MAX_CONTENT_BYTES`,
|
|
52
|
+
`ConflictError`, `NotFoundError`, and the shared types (`Site`,
|
|
53
|
+
`SiteOptions`, `UncialCmsSiteConfig`, `ForgeSession`, `SessionProvider`,
|
|
54
|
+
`ForgeAdapter`).
|
|
55
|
+
- `uncial-cms/svelte` — `EditorPage`, the Svelte component that is the
|
|
56
|
+
recommended door onto an Editor variant in a SvelteKit site.
|
|
57
|
+
- `uncial-cms/session` — `createEditorSession`: the same editing session
|
|
58
|
+
without a surface, for a framework host that renders Uncial's `Editor`
|
|
59
|
+
itself.
|
|
60
|
+
- `uncial-cms/sveltekit` — build-time route factories for prerendered
|
|
61
|
+
SvelteKit sites (`createContentHandlers`, `createEditorHandlers`,
|
|
62
|
+
`createIndexHandlers`).
|
|
63
|
+
- `uncial-cms/vite` — `uncialCms(siteOptions)`, the Vite plugins a site
|
|
64
|
+
installs: the development editing endpoint, and the build's forge literal.
|
|
65
|
+
- `uncial-cms/paths` — the pure path↔source mapping and the page-path
|
|
66
|
+
validators, with no `node:` module and no `@sveltejs/kit` in the import
|
|
67
|
+
graph, so a plain Node script or a browser bundle can import it.
|
|
68
|
+
- `uncial-cms/github` — the GitHub `ForgeAdapter` (`createGitHubAdapter`) over
|
|
69
|
+
the Contents API, plus the two session providers.
|
|
70
|
+
- `uncial-cms/local` — the local filesystem `ForgeAdapter`
|
|
71
|
+
(`createLocalAdapter`), its unauthenticated `localSessionProvider`, and
|
|
72
|
+
`createLocalVitePlugin` for the development endpoint.
|
|
73
|
+
|
|
74
|
+
## Declare the site once
|
|
75
|
+
|
|
76
|
+
A site's configuration is declared once and **resolved for the current build**
|
|
77
|
+
by `defineSite`. Put it in a module with no Svelte and no Kit in its graph, so
|
|
78
|
+
that both the app and `vite.config.ts` can read it:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// site.options.ts
|
|
82
|
+
import type { SiteOptions } from 'uncial-cms';
|
|
83
|
+
|
|
84
|
+
export const STATIC_DIR = 'static';
|
|
85
|
+
|
|
86
|
+
export const siteOptions: SiteOptions = {
|
|
87
|
+
contentDir: 'content', // repo-root-relative, as the forge addresses it
|
|
88
|
+
localContentDir: 'content', // FS path at build time; defaults to contentDir
|
|
89
|
+
mediaDir: `${STATIC_DIR}/uploads`, // repo-root-relative; must sit under the static dir
|
|
90
|
+
github: { repo: 'owner/name', branch: 'main' },
|
|
91
|
+
autosaveMs: 400 // honoured only when the resolved forge is local
|
|
92
|
+
};
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// src/lib/site.ts
|
|
97
|
+
import { defineSite } from 'uncial-cms';
|
|
98
|
+
import { siteOptions } from '../../site.options.js';
|
|
99
|
+
|
|
100
|
+
export const site = defineSite(siteOptions);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The **site object** it returns is `{ config, localOnly, autosaveMs,
|
|
104
|
+
localContentDir }`. `config` is an ordinary `UncialCmsSiteConfig`, so every
|
|
105
|
+
entry point that took a config still does; the route factories and `EditorPage`
|
|
106
|
+
take the whole object, while `mountEditorPage` and `mountIndexPage` keep taking
|
|
107
|
+
`config` — pass `site.config`.
|
|
108
|
+
|
|
109
|
+
- In a **development** build the resolved forge is the local checkout.
|
|
110
|
+
- In a **production** build it is GitHub when a `github` half is declared.
|
|
111
|
+
- Omit `github` and the site is **local-only**: no forge, and no Editor variant
|
|
112
|
+
in the production build. See [Local-only sites](#local-only-sites).
|
|
113
|
+
|
|
114
|
+
`authWorkerUrl` and `appSlug` default to the canonical auth worker
|
|
115
|
+
(`DEFAULT_AUTH_WORKER_URL`) and the canonical `uncial-cms` GitHub App
|
|
116
|
+
(`DEFAULT_APP_SLUG`), so the GitHub half is usually a repository and a branch.
|
|
117
|
+
Set them to self-host the worker or run your own App.
|
|
118
|
+
|
|
119
|
+
`autosaveMs` applies only when the resolved forge is local — on a forge every
|
|
120
|
+
keystroke would be a commit — so the same declaration is right in both builds.
|
|
121
|
+
|
|
122
|
+
## Vite plugins
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// vite.config.ts
|
|
126
|
+
import { sveltekit } from '@sveltejs/kit/vite';
|
|
127
|
+
import { defineConfig } from 'vite';
|
|
128
|
+
import { uncialCms } from 'uncial-cms/vite';
|
|
129
|
+
import { siteOptions } from './site.options.js';
|
|
130
|
+
|
|
131
|
+
export default defineConfig({
|
|
132
|
+
plugins: [...uncialCms(siteOptions), sveltekit()]
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`uncialCms` installs two things: the development-only local editing endpoint,
|
|
137
|
+
rooted at the repository and permitting writes under the content directory and
|
|
138
|
+
`mediaDir`; and a `define` of the forge the build targets, which is how a
|
|
139
|
+
local-only production build drops the editor stack statically rather than merely
|
|
140
|
+
leaving it unrouted.
|
|
141
|
+
|
|
142
|
+
`createLocalVitePlugin` from `uncial-cms/local` is the same endpoint on its own,
|
|
143
|
+
for a site that is not using `defineSite`; it takes the repository `root` and the
|
|
144
|
+
repo-root-relative `permittedRoots` writes are confined to. It is serve-only and
|
|
145
|
+
forces Vite to bind to `127.0.0.1`. Every path it takes is repo-root-relative,
|
|
146
|
+
exactly as the GitHub adapter addresses one. The adapter calls it at the fixed,
|
|
147
|
+
development-only JSON endpoint `/__uncial-cms/local`:
|
|
148
|
+
|
|
149
|
+
- `POST /files/<path>` with `{}` reads a document and returns
|
|
150
|
+
`{ content, sha }`.
|
|
151
|
+
- `PUT /files/<path>` with `{ content }` writes UTF-8 text and returns
|
|
152
|
+
`{ sha, commitSha }`. Binary writes use `{ content, encoding: 'base64' }`.
|
|
153
|
+
- `DELETE /files/<path>` with `{}` deletes a document.
|
|
154
|
+
- `POST /dirs/<path>` with `{}` returns `{ entries }`, where each entry has
|
|
155
|
+
`path` and `type` (`'file'` or `'dir'`).
|
|
156
|
+
|
|
157
|
+
All requests use `Content-Type: application/json`; contents are capped at
|
|
158
|
+
`MAX_CONTENT_BYTES`. The middleware resolves each URL path before checking that
|
|
159
|
+
it remains beneath one of the permitted roots, and writes through a temporary file followed
|
|
160
|
+
by rename, so a watcher never sees a partial document at its target path.
|
|
161
|
+
|
|
162
|
+
## Quick start: SvelteKit
|
|
163
|
+
|
|
164
|
+
Three small route pairs. The factories never register a route — you own the
|
|
165
|
+
files, so the routes stay explicit and debuggable. Declare prerendering and
|
|
166
|
+
trailing slashes once at the root layout:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// src/routes/+layout.ts
|
|
170
|
+
export const prerender = true;
|
|
171
|
+
export const trailingSlash = 'always';
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Alongside `src/lib/site.ts` above, a module for the blocks, the schema and the
|
|
175
|
+
site the routes take:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// src/routes/site.ts
|
|
179
|
+
import { createBlockRegistry, createSchema } from 'uncial/core';
|
|
180
|
+
|
|
181
|
+
export { site } from '$lib/site.js';
|
|
182
|
+
|
|
183
|
+
export const blocks = createBlockRegistry([]);
|
|
184
|
+
export const schema = createSchema(blocks, {
|
|
185
|
+
metaFields: { title: { default: 'Untitled page', required: true } }
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**1. Content catch-all** — `src/routes/[...path]/+page.server.ts`. Prerendered,
|
|
190
|
+
and shipping no CMS JavaScript:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { createContentHandlers } from 'uncial-cms/sveltekit';
|
|
194
|
+
import { blocks, schema, site } from '../site.js';
|
|
195
|
+
|
|
196
|
+
const handlers = createContentHandlers({ site, blocks, schema });
|
|
197
|
+
|
|
198
|
+
export const entries = handlers.entries; // derived from the content dir
|
|
199
|
+
export const load = handlers.load; // { document, meta, path }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**2. Editor variant** — `src/routes/[...path]/edit/+page.server.ts` bakes the
|
|
203
|
+
mapping only; the document is always fetched live from the forge, never read
|
|
204
|
+
out of the build:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import { createEditorHandlers } from 'uncial-cms/sveltekit';
|
|
208
|
+
import { blocks, schema, site } from '../../site.js';
|
|
209
|
+
|
|
210
|
+
const handlers = createEditorHandlers({ site, blocks, schema });
|
|
211
|
+
|
|
212
|
+
export const entries = handlers.entries;
|
|
213
|
+
export const load = handlers.load; // { sourcePath, pagePath }
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```svelte
|
|
217
|
+
<!-- src/routes/[...path]/edit/+page.svelte -->
|
|
218
|
+
<script lang="ts">
|
|
219
|
+
import { EditorPage } from 'uncial-cms/svelte';
|
|
220
|
+
import { blocks, schema, site } from '../../site.js';
|
|
221
|
+
|
|
222
|
+
let { data } = $props();
|
|
223
|
+
</script>
|
|
224
|
+
|
|
225
|
+
<main>
|
|
226
|
+
<article class="uncial-rich-content">
|
|
227
|
+
<EditorPage {site} {blocks} {schema} sourcePath={data.sourcePath} pagePath={data.pagePath} />
|
|
228
|
+
</article>
|
|
229
|
+
</main>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Put `EditorPage` where the Content page's `Renderer` sits, inside the same
|
|
233
|
+
shell. That is the whole of the parity work: the component renders Uncial's
|
|
234
|
+
`Editor` in the **light DOM**, in your tree and your cascade, so every rule your
|
|
235
|
+
site sets already applies and none of them has to be restated. It owns the rest
|
|
236
|
+
of the editing surface too — the status line with its commit link, the blocking
|
|
237
|
+
conflict banner with download/reload/dismiss, seeding the metadata panel from
|
|
238
|
+
the loaded document, the Save button under a forge and autosave under the local
|
|
239
|
+
one. It imports the editor's chrome stylesheet itself, loads the editor stack
|
|
240
|
+
dynamically, and carries the runtime sentinel the CLI gate looks for.
|
|
241
|
+
|
|
242
|
+
Its props:
|
|
243
|
+
|
|
244
|
+
| Prop | Type | Notes |
|
|
245
|
+
| --- | --- | --- |
|
|
246
|
+
| `site` | `Site` | The object from `defineSite`. |
|
|
247
|
+
| `sourcePath` | `string` | From the editor route's payload. |
|
|
248
|
+
| `pagePath` | `string` | From the editor route's payload. |
|
|
249
|
+
| `blocks` | `BlockRegistry` | |
|
|
250
|
+
| `schema` | `ContentSchema \| (path: string) => ContentSchema` | |
|
|
251
|
+
| `sessionProvider` | `SessionProvider` | Defaults to the provider the resolved forge implies. |
|
|
252
|
+
| `attributesPanel` | `'docked' \| 'overlay' \| 'off'` | Default `'overlay'`: the panel costs the document no width. |
|
|
253
|
+
| `presentation` | `'card' \| 'bare'` | Default `'bare'`: no surface of the editor's own. |
|
|
254
|
+
|
|
255
|
+
Pass `devOnly: true` to `createEditorHandlers` for a **GitHub** site that still
|
|
256
|
+
wants editing in development only; a local-only site gets that behaviour without
|
|
257
|
+
saying so. Kit reports a prerenderable route it never crawled, so a build strict
|
|
258
|
+
about that also names the route in `prerender.handleUnseenRoutes`.
|
|
259
|
+
|
|
260
|
+
**3. Site index** — `src/routes/uncial/+page.server.ts` + `+page.svelte` (OAuth
|
|
261
|
+
landing, create/delete, hash-routed fallback editor). The Index page keeps the
|
|
262
|
+
plain-DOM mount:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { createIndexHandlers } from 'uncial-cms/sveltekit';
|
|
266
|
+
import { blocks, schema, site } from '../site.js';
|
|
267
|
+
|
|
268
|
+
const handlers = createIndexHandlers({ site, blocks, schema });
|
|
269
|
+
export const load = handlers.load;
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```svelte
|
|
273
|
+
<!-- src/routes/uncial/+page.svelte -->
|
|
274
|
+
<script lang="ts">
|
|
275
|
+
import { onMount } from 'svelte';
|
|
276
|
+
import { base } from '$app/paths';
|
|
277
|
+
import { mountIndexPage } from 'uncial-cms';
|
|
278
|
+
import { blocks, schema, site } from '../site.js';
|
|
279
|
+
|
|
280
|
+
let target: HTMLElement;
|
|
281
|
+
onMount(() => {
|
|
282
|
+
const handle = mountIndexPage(target, { config: site.config, blocks, schema, basePath: base });
|
|
283
|
+
return () => handle.destroy();
|
|
284
|
+
});
|
|
285
|
+
</script>
|
|
286
|
+
|
|
287
|
+
<div bind:this={target}></div>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
When the site is served under a base path (e.g. GitHub Pages project sites at
|
|
291
|
+
`/owner/repo/`), pass the framework's base path to `mountIndexPage`
|
|
292
|
+
(`basePath`) so live-page links resolve; the default mapping always operates on
|
|
293
|
+
**site-relative** paths with the base stripped.
|
|
294
|
+
|
|
295
|
+
### Excluding non-page files, and a schema per path
|
|
296
|
+
|
|
297
|
+
A content directory usually holds files that are not pages. Both handlers take
|
|
298
|
+
an `exclude` predicate over `{ path, source }`, and `schema` accepts a resolver
|
|
299
|
+
as readily as a single schema — so an essay can require metadata a landing page
|
|
300
|
+
does not, without instantiating the factories twice:
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
const handlers = createContentHandlers({
|
|
304
|
+
site,
|
|
305
|
+
blocks,
|
|
306
|
+
exclude: ({ path }) => path === 'site-settings',
|
|
307
|
+
schema: (path) => (path.startsWith('essays/') ? essaySchema : pageSchema)
|
|
308
|
+
});
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Pass the same resolver to `EditorPage` and to the Content page's renderer, and
|
|
312
|
+
one page path means one schema everywhere.
|
|
313
|
+
|
|
314
|
+
## Local-only sites
|
|
315
|
+
|
|
316
|
+
Omitting the `github` half is the whole of the configuration for a site edited
|
|
317
|
+
only by whoever holds the checkout:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
export const siteOptions: SiteOptions = { contentDir: 'content', autosaveMs: 400 };
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
No App, no allowlist, no worker, no `doctor`. Everything else — the route pairs,
|
|
324
|
+
the blocks, the schema, `EditorPage` — is unchanged. `pnpm dev` and `/about/edit/`
|
|
325
|
+
edit the checkout through the local endpoint, with autosave and no Save button.
|
|
326
|
+
|
|
327
|
+
The production build has no Editor variants: the editor routes prerender no
|
|
328
|
+
entries, and `EditorPage` gates its dynamic imports on a build-time literal, so
|
|
329
|
+
the editor stack is unreachable and the bundler drops it. Assert it with
|
|
330
|
+
`assert-clean-pages --local-only`.
|
|
331
|
+
|
|
332
|
+
## The headless session
|
|
333
|
+
|
|
334
|
+
`uncial-cms/session` is the same batteries minus the surface, for a React, Vue
|
|
335
|
+
or other framework host that renders Uncial's `Editor` itself. It is what
|
|
336
|
+
`EditorPage` is built on. Reach it at the subpath rather than through the
|
|
337
|
+
package root: the root exports `mountEditorPage` too, and importing that pulls
|
|
338
|
+
in the custom element, its shadow-root machinery and the editor's chrome
|
|
339
|
+
stylesheet, which a host rendering its own surface neither wants loaded nor
|
|
340
|
+
wants arriving after its own corrections to that stylesheet.
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
import { createEditorSession } from 'uncial-cms/session';
|
|
344
|
+
|
|
345
|
+
const controller = createEditorSession({
|
|
346
|
+
config: site.config,
|
|
347
|
+
sourcePath,
|
|
348
|
+
pagePath,
|
|
349
|
+
blocks,
|
|
350
|
+
schema,
|
|
351
|
+
autosaveMs: site.autosaveMs,
|
|
352
|
+
ui: {
|
|
353
|
+
status: (view) => renderStatus(view), // tone + message + optional commit link
|
|
354
|
+
setDocument: (doc) => seedEditorAndMeta(doc), // seed metadata from the document, not the schema
|
|
355
|
+
saveEnabled: (enabled) => setSaveEnabled(enabled),
|
|
356
|
+
conflictVisible: (visible) => setConflictVisible(visible)
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
await controller.load();
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
The host hands the session four callbacks and each edit as it happens; the
|
|
364
|
+
session owns storage, autosave, deploy-status polling and conflict recovery.
|
|
365
|
+
`forgeAdapter(config)` and `defaultSessionProvider(config)` are exported for a
|
|
366
|
+
host that wants the pieces directly.
|
|
367
|
+
|
|
368
|
+
## Plain-HTML / web-component usage
|
|
369
|
+
|
|
370
|
+
`mountEditorPage` is the door for a host with no component model. It mounts into
|
|
371
|
+
any `HTMLElement`, builds a custom element with a shadow root, and pulls in
|
|
372
|
+
Uncial's web-components entry itself — no build step and no framework required.
|
|
373
|
+
A SvelteKit site should prefer `EditorPage`: the shadow boundary that buys style
|
|
374
|
+
isolation costs a framework host every rule the page sets on `body`, which is
|
|
375
|
+
where most sites put their type and their ground.
|
|
376
|
+
|
|
377
|
+
```html
|
|
378
|
+
<div id="editor"></div>
|
|
379
|
+
<script type="module">
|
|
380
|
+
import { mountEditorPage } from 'uncial-cms';
|
|
381
|
+
import { createBlockRegistry, createSchema } from 'uncial/core';
|
|
382
|
+
|
|
383
|
+
const blocks = createBlockRegistry([]);
|
|
384
|
+
const schema = createSchema(blocks, {
|
|
385
|
+
metaFields: { title: { default: 'Untitled', required: true } }
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
mountEditorPage(document.getElementById('editor'), {
|
|
389
|
+
config: {
|
|
390
|
+
forge: 'github',
|
|
391
|
+
repo: 'owner/name',
|
|
392
|
+
branch: 'main',
|
|
393
|
+
contentDir: 'content',
|
|
394
|
+
authWorkerUrl: 'https://uncial-cms-auth.dflood.workers.dev',
|
|
395
|
+
appSlug: 'uncial-cms'
|
|
396
|
+
},
|
|
397
|
+
sourcePath: 'content/about.json',
|
|
398
|
+
blocks,
|
|
399
|
+
schema
|
|
400
|
+
});
|
|
401
|
+
</script>
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
It takes the same parity options `EditorPage` does — `attributesPanel:
|
|
405
|
+
'overlay'` so the block attributes panel costs the document no width, and
|
|
406
|
+
`presentation: 'bare'` so the editor draws no surface of its own — plus
|
|
407
|
+
`editorStylesheets`, which defaults to mirroring the host page's stylesheets
|
|
408
|
+
into the shadow root. Pass `autosaveMs` to drop the Save button and write the
|
|
409
|
+
document that many milliseconds after the last change instead — the mode a local
|
|
410
|
+
checkout wants, where the file *is* the document. Changes inside the window
|
|
411
|
+
coalesce into one write, and a change arriving mid-write queues a single
|
|
412
|
+
follow-up:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
mountEditorPage(target, { config, sourcePath, blocks, schema, autosaveMs: 400 });
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Leave `autosaveMs` unset on a forge, where every keystroke would become a commit.
|
|
419
|
+
|
|
420
|
+
## Session providers
|
|
421
|
+
|
|
422
|
+
Authentication is owned entirely by the session provider — the `sessionProvider`
|
|
423
|
+
option on `EditorPage`, `createEditorSession`, `mountEditorPage` and
|
|
424
|
+
`mountIndexPage`. It defaults to the provider the resolved forge implies. Two
|
|
425
|
+
ship in the box, plus the unauthenticated `localSessionProvider` the local forge
|
|
426
|
+
uses:
|
|
427
|
+
|
|
428
|
+
- **`popupSessionProvider` (default under GitHub).** Opens the auth worker in a
|
|
429
|
+
popup, runs a PKCE dance, and receives a GitHub App installation token
|
|
430
|
+
**scoped to the one configured repository** (~1 hour, contents read/write).
|
|
431
|
+
The user's OAuth token never reaches the browser. Requires `authWorkerUrl` in
|
|
432
|
+
the config and the [`uncial-cms-auth`](../uncial-cms-auth) worker (a canonical
|
|
433
|
+
hosted instance exists; self-hosting is first-class). Tokens live in
|
|
434
|
+
`sessionStorage` per repo; on expiry the popup re-runs, and GitHub skips
|
|
435
|
+
re-consent so renewal is a flash.
|
|
436
|
+
- **`patSessionProvider`.** Zero-backend mode: prompts for a fine-grained
|
|
437
|
+
personal access token (contents read/write on the target repo) and validates
|
|
438
|
+
it via `GET /user`. The permanent dev / self-service mode — no worker needed.
|
|
439
|
+
|
|
440
|
+
```svelte
|
|
441
|
+
<script lang="ts">
|
|
442
|
+
import { patSessionProvider } from 'uncial-cms';
|
|
443
|
+
import { EditorPage } from 'uncial-cms/svelte';
|
|
444
|
+
</script>
|
|
445
|
+
|
|
446
|
+
<EditorPage {site} {blocks} {schema} {sourcePath} {pagePath} sessionProvider={patSessionProvider} />
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## The `uncial-cms` command
|
|
450
|
+
|
|
451
|
+
The package ships a `bin` with two commands.
|
|
452
|
+
|
|
453
|
+
```sh
|
|
454
|
+
pnpm exec uncial-cms assert-clean-pages [buildDir] [--local-only]
|
|
455
|
+
pnpm exec uncial-cms doctor --origin https://example.com [--repo owner/name]
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
**`assert-clean-pages`** is the zero-CMS-JS build gate. It walks every
|
|
459
|
+
`index.html` in the build (default `build`), resolves the static import closure
|
|
460
|
+
of its scripts, and checks the runtime sentinel: absent from every Content page,
|
|
461
|
+
present on every Editor variant, ignored on the Index page. With `--local-only`
|
|
462
|
+
it asserts a local-only site's build instead — no Editor variant exists, and no
|
|
463
|
+
page's closure carries the sentinel or the editor stack.
|
|
464
|
+
|
|
465
|
+
**`doctor`** is the provisioning check, run before the first sign-in rather than
|
|
466
|
+
learned from it. Through the authenticated `gh` CLI it checks that the GitHub
|
|
467
|
+
App is installed on the repository, that `.uncial/cms.json` is committed on the
|
|
468
|
+
default branch and lists the site's origin, and that Pages is enabled and serves
|
|
469
|
+
that origin — with a custom domain matching it when the origin is not a
|
|
470
|
+
`github.io` one. Each failure names the auth worker refusal it would have
|
|
471
|
+
produced. `--repo` defaults to the current checkout's repository, `--app-slug`
|
|
472
|
+
to `uncial-cms`, and `--branch` to the default branch. A missing `gh` is a clear
|
|
473
|
+
message, not a stack trace.
|
|
474
|
+
|
|
475
|
+
## Reusable deploy workflow
|
|
476
|
+
|
|
477
|
+
Uncial publishes a `workflow_call` workflow that does pnpm and Node setup, a
|
|
478
|
+
frozen install, check, unit tests, the build with the caller's base path, the
|
|
479
|
+
clean-pages gate in the caller's mode, upload and deploy to Pages. A consumer's
|
|
480
|
+
own workflow names the trigger, the permissions and the call:
|
|
481
|
+
|
|
482
|
+
```yaml
|
|
483
|
+
# .github/workflows/pages.yml
|
|
484
|
+
name: Pages
|
|
485
|
+
on:
|
|
486
|
+
push: { branches: [main] }
|
|
487
|
+
workflow_dispatch:
|
|
488
|
+
permissions: { contents: read, pages: write, id-token: write }
|
|
489
|
+
jobs:
|
|
490
|
+
site:
|
|
491
|
+
uses: d-flood/uncial/.github/workflows/static-site.yml@main
|
|
492
|
+
with:
|
|
493
|
+
base-path: /${{ github.event.repository.name }}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Permissions are the caller's — a reusable workflow inherits the calling job's
|
|
497
|
+
token scopes, so those three must be declared there. `base-path` is exported to
|
|
498
|
+
the build as `BASE_PATH`; a Pages *project* site is served under `/<repo>/`,
|
|
499
|
+
while a user, organization or custom-domain site is served at the root and needs
|
|
500
|
+
no value. The other inputs are seams rather than everyday knobs:
|
|
501
|
+
`package-filter` for a monorepo, `build-dir`, `local-only` for the gate's other
|
|
502
|
+
mode, `run-unit-tests`, `deploy`, `pre-build`, and `artifact-name` for a caller
|
|
503
|
+
that merges several builds into one Pages artifact. This repository's own docs
|
|
504
|
+
deploy uses the last two and is the workflow's live exercise.
|
|
505
|
+
|
|
506
|
+
## Security model
|
|
507
|
+
|
|
508
|
+
- **Repo-scoped tokens.** The token delivered to the browser is a GitHub App
|
|
509
|
+
installation token restricted to a single repository (`repositories: [name]`)
|
|
510
|
+
with contents read/write and ~1h expiry. A leaked token cannot reach the
|
|
511
|
+
user's other repositories, and there is no user token in the browser at all.
|
|
512
|
+
- **Origin↔repo allowlist.** The worker mints a token only if the initiating
|
|
513
|
+
origin appears in `.uncial/cms.json`, committed to the target repo's default
|
|
514
|
+
branch, so a malicious origin cannot claim to be the editor for a repo whose
|
|
515
|
+
owners never listed it:
|
|
516
|
+
|
|
517
|
+
```json
|
|
518
|
+
// .uncial/cms.json
|
|
519
|
+
{ "allowedOrigins": ["https://example.com", "http://localhost:5173"] }
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
- **sha-checked writes.** Concurrent edits produce a 409 and an explicit user
|
|
523
|
+
choice, never a silent overwrite. Editor variants and the index must not load
|
|
524
|
+
third-party scripts — the blast radius of XSS is capped at one repo for ≤1h.
|
|
525
|
+
|
|
526
|
+
Residual risks accepted for v1 (see the spec's §6.5 for the full treatment):
|
|
527
|
+
|
|
528
|
+
- A user with push access can add origins to `.uncial/cms.json`; branch
|
|
529
|
+
protection on that path is the site owner's mitigation.
|
|
530
|
+
- On **shared-origin hosts** (e.g. `<user>.github.io`, where every project page
|
|
531
|
+
of an account shares one origin), allowlisting the origin authorizes every
|
|
532
|
+
site served from it. Acceptable when all sites on the origin belong to the repo
|
|
533
|
+
owner — the demo's case — but on a shared host a **custom domain restores
|
|
534
|
+
per-site granularity**.
|
|
535
|
+
- Phishing remains possible in principle; the repo-scoped token caps the damage
|
|
536
|
+
to repos the victim can push to *and* whose allowlist names the attacker
|
|
537
|
+
origin — i.e. near zero.
|
|
538
|
+
|
|
539
|
+
## Media
|
|
540
|
+
|
|
541
|
+
Single-image upload commits an image straight into the repo and hands the block
|
|
542
|
+
back its served path. It is exposed as pure, adapter-injected functions in the
|
|
543
|
+
same family as `createPage`/`deletePage`:
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
import { uploadAsset, uploadImageAsset, servedUrl, MAX_CONTENT_BYTES } from 'uncial-cms';
|
|
547
|
+
|
|
548
|
+
// Pure form — inject an adapter (mirrors createPage). Returns the committed path.
|
|
549
|
+
const { path, sha, commitSha } = await uploadAsset(
|
|
550
|
+
{ adapter },
|
|
551
|
+
{ bytes, filename: 'diagram.png', contentType: 'image/png' },
|
|
552
|
+
{ mediaDir: 'static/uploads', author: { name, email } }
|
|
553
|
+
);
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
From a block, use the editor convenience: it resolves the adapter and author
|
|
557
|
+
from the active editor session, and reads `mediaDir` off the site object, so no
|
|
558
|
+
block hardcodes a directory. Import it **dynamically**, inside the handler, so
|
|
559
|
+
`uncial-cms` never enters a reader page's static import graph:
|
|
560
|
+
|
|
561
|
+
```ts
|
|
562
|
+
const { servedUrl, uploadImageAsset } = await import('uncial-cms');
|
|
563
|
+
const result = await uploadImageAsset(file, { site, fit: true });
|
|
564
|
+
updateAttributes?.({ src: servedUrl(site, result.path, STATIC_DIR) });
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
- **`fit`.** `true` (or a `FitOptions` object) re-encodes an oversize image to
|
|
568
|
+
WebP at a bounded longest edge, stepping quality and then dimensions down
|
|
569
|
+
until the bytes fit the forge limit — so a photograph off a phone commits
|
|
570
|
+
instead of failing the Contents API cap. An image already under the limit
|
|
571
|
+
passes through untouched. `fitImage` is exported for use on its own.
|
|
572
|
+
- **`servedUrl(site, repoPath, staticDir?)`** maps a committed repo-root path
|
|
573
|
+
under the static directory to the site-root-relative URL the built site serves
|
|
574
|
+
it from. It carries **no base path**: a stored `src` must stay correct at
|
|
575
|
+
every `paths.base` the same content is built at, so the site prepends its base
|
|
576
|
+
at render time.
|
|
577
|
+
- **Content-addressed.** The committed name is a hash of the bytes plus the
|
|
578
|
+
original extension (`<mediaDir>/<hash>.<ext>`), so re-uploading identical bytes
|
|
579
|
+
reuses the existing file (idempotent create) and distinct images never collide.
|
|
580
|
+
- **Size guard.** Files over `MAX_CONTENT_BYTES` (~1 MB, the GitHub Contents API
|
|
581
|
+
limit) reject with a clear, catchable error before any network call — there is
|
|
582
|
+
no git-blobs-API fallback in v1. With `fit`, the re-encode happens first.
|
|
583
|
+
- **`mediaDir`** is repo-root-relative and comes from the site options; the
|
|
584
|
+
returned `path` is repo-root-relative too. Until the next redeploy the
|
|
585
|
+
committed copy is not served, so the editor should bridge the gap with a local
|
|
586
|
+
`objectURL` preview.
|
|
587
|
+
|
|
588
|
+
The `ForgeAdapter`'s `writeFile` accepts `string | Uint8Array`; binary content is
|
|
589
|
+
base64-encoded and PUT to the Contents API with the same message/branch/sha
|
|
590
|
+
contract as text writes (conflicts still surface as `ConflictError`).
|
|
591
|
+
|
|
592
|
+
## v1 limitations & roadmap
|
|
593
|
+
|
|
594
|
+
Deliberately out of scope for v1 (tracked as future design rounds):
|
|
595
|
+
|
|
596
|
+
- **Single-image upload only.** [Media](#media) commits one image at a time, up
|
|
597
|
+
to the ~1 MB Contents API limit. `fit` downscales to reach it; there are no
|
|
598
|
+
multi-image/galleries, no video, and no >1 MB via the blobs API.
|
|
599
|
+
- **No drafts / PR workflows.** Save commits directly to the configured branch.
|
|
600
|
+
Editorial review in v1 is git **branch protection**, configured by the site
|
|
601
|
+
owner. A save-to-branch toggle is a candidate for v1.x.
|
|
602
|
+
- **GitHub and local files only.** v1 ships the forge-adapter *interface*
|
|
603
|
+
(validated on paper against GitLab's client-side PKCE), the GitHub
|
|
604
|
+
implementation, and a development-only local filesystem implementation.
|
|
605
|
+
**GitLab** / Gitea adapters are future work; nothing outside the adapter
|
|
606
|
+
assumes a worker exists.
|
|
607
|
+
- **No scaffold command.** The per-site remainder is a config module, three
|
|
608
|
+
route pairs, one JSON file and a ten-line workflow; a generator is worth
|
|
609
|
+
writing once more projects have exercised that remainder.
|
|
610
|
+
|
|
611
|
+
## Development
|
|
612
|
+
|
|
613
|
+
```sh
|
|
614
|
+
pnpm run check # svelte-check
|
|
615
|
+
pnpm run test:unit -- --run # vitest (node)
|
|
616
|
+
pnpm run test:e2e # Playwright: plain-HTML fixture + built demo + base-path demo
|
|
617
|
+
pnpm run prepack # svelte-package → dist, then publint
|
|
618
|
+
```
|
package/dist/base64.d.ts
ADDED
package/dist/base64.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function bytesToBase64(bytes) {
|
|
2
|
+
let binary = '';
|
|
3
|
+
for (const byte of bytes)
|
|
4
|
+
binary += String.fromCharCode(byte);
|
|
5
|
+
return btoa(binary);
|
|
6
|
+
}
|
|
7
|
+
export function encodeBase64(text) {
|
|
8
|
+
return bytesToBase64(new TextEncoder().encode(text));
|
|
9
|
+
}
|
|
10
|
+
export function decodeBase64(base64) {
|
|
11
|
+
// The Contents API wraps base64 payloads in newlines.
|
|
12
|
+
const binary = atob(base64.replaceAll('\n', ''));
|
|
13
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
14
|
+
return new TextDecoder().decode(bytes);
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Where the command writes; the specs collect it instead of printing it. */
|
|
2
|
+
export interface CliOutput {
|
|
3
|
+
out(line: string): void;
|
|
4
|
+
err(line: string): void;
|
|
5
|
+
}
|
|
6
|
+
export interface AssertCleanPagesOptions {
|
|
7
|
+
/** Assert a local-only site: no Editor variant, and no editor stack anywhere. */
|
|
8
|
+
localOnly?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function assertCleanPages(buildDir: string, options: AssertCleanPagesOptions, io: CliOutput): number;
|