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.
Files changed (77) hide show
  1. package/README.md +618 -0
  2. package/dist/base64.d.ts +3 -0
  3. package/dist/base64.js +15 -0
  4. package/dist/cli/assert-clean-pages.d.ts +10 -0
  5. package/dist/cli/assert-clean-pages.js +153 -0
  6. package/dist/cli/bin.d.ts +2 -0
  7. package/dist/cli/bin.js +3 -0
  8. package/dist/cli/doctor.d.ts +23 -0
  9. package/dist/cli/doctor.js +217 -0
  10. package/dist/cli/run.d.ts +4 -0
  11. package/dist/cli/run.js +99 -0
  12. package/dist/constants.d.ts +6 -0
  13. package/dist/constants.js +6 -0
  14. package/dist/define-site.d.ts +37 -0
  15. package/dist/define-site.js +24 -0
  16. package/dist/deploy-status.d.ts +55 -0
  17. package/dist/deploy-status.js +118 -0
  18. package/dist/document.d.ts +6 -0
  19. package/dist/document.js +23 -0
  20. package/dist/editor-controller.d.ts +76 -0
  21. package/dist/editor-controller.js +172 -0
  22. package/dist/editor-session.d.ts +60 -0
  23. package/dist/editor-session.js +63 -0
  24. package/dist/errors.d.ts +8 -0
  25. package/dist/errors.js +14 -0
  26. package/dist/fit-image.d.ts +31 -0
  27. package/dist/fit-image.js +88 -0
  28. package/dist/github/adapter.d.ts +3 -0
  29. package/dist/github/adapter.js +135 -0
  30. package/dist/github/index.d.ts +3 -0
  31. package/dist/github/index.js +3 -0
  32. package/dist/github/pat.d.ts +7 -0
  33. package/dist/github/pat.js +35 -0
  34. package/dist/github/popup.d.ts +9 -0
  35. package/dist/github/popup.js +75 -0
  36. package/dist/index-actions.d.ts +74 -0
  37. package/dist/index-actions.js +147 -0
  38. package/dist/index-page.d.ts +19 -0
  39. package/dist/index-page.js +224 -0
  40. package/dist/index.d.ts +15 -0
  41. package/dist/index.js +21 -0
  42. package/dist/local/adapter.d.ts +2 -0
  43. package/dist/local/adapter.js +63 -0
  44. package/dist/local/constants.d.ts +1 -0
  45. package/dist/local/constants.js +1 -0
  46. package/dist/local/index.d.ts +4 -0
  47. package/dist/local/index.js +4 -0
  48. package/dist/local/session.d.ts +2 -0
  49. package/dist/local/session.js +12 -0
  50. package/dist/local/vite.d.ts +8 -0
  51. package/dist/local/vite.js +243 -0
  52. package/dist/mount.d.ts +43 -0
  53. package/dist/mount.js +151 -0
  54. package/dist/paths/index.d.ts +17 -0
  55. package/dist/paths/index.js +47 -0
  56. package/dist/sentinel.d.ts +6 -0
  57. package/dist/sentinel.js +6 -0
  58. package/dist/served-url.d.ts +16 -0
  59. package/dist/served-url.js +19 -0
  60. package/dist/session.d.ts +4 -0
  61. package/dist/session.js +30 -0
  62. package/dist/svelte/EditorPage.svelte +178 -0
  63. package/dist/svelte/EditorPage.svelte.d.ts +23 -0
  64. package/dist/svelte/index.d.ts +5 -0
  65. package/dist/svelte/index.js +5 -0
  66. package/dist/svelte/styles.d.ts +4 -0
  67. package/dist/sveltekit/index.d.ts +68 -0
  68. package/dist/sveltekit/index.js +98 -0
  69. package/dist/sveltekit/mapping.d.ts +1 -0
  70. package/dist/sveltekit/mapping.js +1 -0
  71. package/dist/types.d.ts +53 -0
  72. package/dist/types.js +1 -0
  73. package/dist/upload-context.d.ts +24 -0
  74. package/dist/upload-context.js +10 -0
  75. package/dist/vite/index.d.ts +9 -0
  76. package/dist/vite/index.js +49 -0
  77. 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
+ ```
@@ -0,0 +1,3 @@
1
+ export declare function bytesToBase64(bytes: Uint8Array): string;
2
+ export declare function encodeBase64(text: string): string;
3
+ export declare function decodeBase64(base64: string): string;
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;