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
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-save deploy-status lifecycle (ticket 05, SPEC §6.6 / D5).
|
|
3
|
+
*
|
|
4
|
+
* After a save commits, the runtime polls `adapter.commitStatus(commitSha)` and
|
|
5
|
+
* surfaces the deploy lifecycle: `committed → building… → live` (or
|
|
6
|
+
* `build failed`, or a calm `status unknown` when the repo reports no checks).
|
|
7
|
+
* The timing constants live here; tests shrink them via injection.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_DEPLOY_STATUS_TIMINGS = {
|
|
10
|
+
firstDelayMs: 3_000,
|
|
11
|
+
intervalMs: 10_000,
|
|
12
|
+
timeoutMs: 5 * 60_000
|
|
13
|
+
};
|
|
14
|
+
/** Map a forge commit status to a deploy phase and whether polling should stop. */
|
|
15
|
+
export function deployPhaseForStatus(status) {
|
|
16
|
+
switch (status) {
|
|
17
|
+
case 'pending':
|
|
18
|
+
return { phase: 'building', done: false };
|
|
19
|
+
case 'success':
|
|
20
|
+
return { phase: 'live', done: true };
|
|
21
|
+
case 'failure':
|
|
22
|
+
return { phase: 'failed', done: true };
|
|
23
|
+
case 'unknown':
|
|
24
|
+
default:
|
|
25
|
+
// No CI configured (or the forge reports nothing): terminal and calm.
|
|
26
|
+
return { phase: 'unknown', done: true };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function githubCommitUrl(repo, commitSha) {
|
|
30
|
+
return `https://github.com/${repo}/commit/${commitSha}`;
|
|
31
|
+
}
|
|
32
|
+
/** Human-facing copy for a phase; the branch is always named (ticket contract). */
|
|
33
|
+
export function describeDeployPhase(phase, ctx) {
|
|
34
|
+
const short = ctx.commitSha.slice(0, 7);
|
|
35
|
+
const base = { commitUrl: ctx.commitUrl };
|
|
36
|
+
switch (phase) {
|
|
37
|
+
case 'committed':
|
|
38
|
+
return { ...base, tone: 'progress', text: `Committed to ${ctx.branch} · checking deploy status…` };
|
|
39
|
+
case 'building':
|
|
40
|
+
return {
|
|
41
|
+
...base,
|
|
42
|
+
tone: 'progress',
|
|
43
|
+
text: `Committed to ${ctx.branch} · building… (usually ~1–2 min)`
|
|
44
|
+
};
|
|
45
|
+
case 'live':
|
|
46
|
+
return { ...base, tone: 'success', text: `Live on ${ctx.branch} · commit ${short}` };
|
|
47
|
+
case 'failed':
|
|
48
|
+
return { ...base, tone: 'error', text: `Build failed on ${ctx.branch} · commit ${short}` };
|
|
49
|
+
case 'unknown':
|
|
50
|
+
return {
|
|
51
|
+
...base,
|
|
52
|
+
tone: 'progress',
|
|
53
|
+
text: `Committed to ${ctx.branch} · commit ${short} (no deploy status reported)`
|
|
54
|
+
};
|
|
55
|
+
case 'timeout':
|
|
56
|
+
return {
|
|
57
|
+
...base,
|
|
58
|
+
tone: 'progress',
|
|
59
|
+
text: `Committed to ${ctx.branch} · status unknown (still building?) · commit ${short}`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export const defaultSchedule = (fn, ms) => {
|
|
64
|
+
const id = setTimeout(fn, ms);
|
|
65
|
+
return () => clearTimeout(id);
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Poll `check` until a terminal status or the timeout. Emits `committed`
|
|
69
|
+
* synchronously, then a phase per poll. Stops on any terminal phase, on
|
|
70
|
+
* timeout, or on `cancel()`. Never throws — a failed check retries until the
|
|
71
|
+
* deadline.
|
|
72
|
+
*/
|
|
73
|
+
export function startDeployPolling(opts) {
|
|
74
|
+
const timings = opts.timings ?? DEFAULT_DEPLOY_STATUS_TIMINGS;
|
|
75
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
76
|
+
let stopped = false;
|
|
77
|
+
let cancelNext = null;
|
|
78
|
+
let cancelDeadline = null;
|
|
79
|
+
const stop = () => {
|
|
80
|
+
stopped = true;
|
|
81
|
+
cancelNext?.();
|
|
82
|
+
cancelDeadline?.();
|
|
83
|
+
cancelNext = null;
|
|
84
|
+
cancelDeadline = null;
|
|
85
|
+
};
|
|
86
|
+
const emit = (phase, done) => {
|
|
87
|
+
if (stopped)
|
|
88
|
+
return;
|
|
89
|
+
opts.onPhase(phase);
|
|
90
|
+
if (done)
|
|
91
|
+
stop();
|
|
92
|
+
};
|
|
93
|
+
const poll = async () => {
|
|
94
|
+
if (stopped)
|
|
95
|
+
return;
|
|
96
|
+
let status;
|
|
97
|
+
try {
|
|
98
|
+
status = await opts.check();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Transient poll failure: keep trying until the deadline fires.
|
|
102
|
+
if (!stopped)
|
|
103
|
+
cancelNext = schedule(() => void poll(), timings.intervalMs);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (stopped)
|
|
107
|
+
return;
|
|
108
|
+
const { phase, done } = deployPhaseForStatus(status);
|
|
109
|
+
emit(phase, done);
|
|
110
|
+
if (!stopped)
|
|
111
|
+
cancelNext = schedule(() => void poll(), timings.intervalMs);
|
|
112
|
+
};
|
|
113
|
+
// The commit landed; announce it before the first poll.
|
|
114
|
+
opts.onPhase('committed');
|
|
115
|
+
cancelDeadline = schedule(() => emit('timeout', true), timings.timeoutMs);
|
|
116
|
+
cancelNext = schedule(() => void poll(), timings.firstDelayMs);
|
|
117
|
+
return { cancel: stop };
|
|
118
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BlockDefinition, BlockRegistry, ContentDocument, ContentSchema } from 'uncial/core';
|
|
2
|
+
export type Blocks = BlockRegistry | BlockDefinition[];
|
|
3
|
+
/** Load boundary: raw forge file content → normalized Uncial document. */
|
|
4
|
+
export declare function parseDocument(raw: string, blocks: Blocks, schema: ContentSchema): ContentDocument;
|
|
5
|
+
/** Save boundary: validate, normalize, and serialize the document for commit. */
|
|
6
|
+
export declare function serializeDocument(document: ContentDocument, blocks: Blocks, schema: ContentSchema): string;
|
package/dist/document.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizeDocument, validateDocument } from 'uncial/core';
|
|
2
|
+
/** Load boundary: raw forge file content → normalized Uncial document. */
|
|
3
|
+
export function parseDocument(raw, blocks, schema) {
|
|
4
|
+
let parsed;
|
|
5
|
+
try {
|
|
6
|
+
parsed = JSON.parse(raw);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw new Error('The document file is not valid JSON.');
|
|
10
|
+
}
|
|
11
|
+
return normalizeDocument(parsed, blocks, schema);
|
|
12
|
+
}
|
|
13
|
+
/** Save boundary: validate, normalize, and serialize the document for commit. */
|
|
14
|
+
export function serializeDocument(document, blocks, schema) {
|
|
15
|
+
const result = validateDocument(document, blocks, schema);
|
|
16
|
+
const errors = result.issues.filter((issue) => issue.severity === 'error');
|
|
17
|
+
if (errors.length > 0) {
|
|
18
|
+
throw new Error(`The document failed validation and was not saved: ${errors
|
|
19
|
+
.map((issue) => issue.message)
|
|
20
|
+
.join('; ')}`);
|
|
21
|
+
}
|
|
22
|
+
return `${JSON.stringify(normalizeDocument(document, blocks, schema), null, '\t')}\n`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless editor-page orchestration (ticket 05). Owns the load → edit → save
|
|
3
|
+
* lifecycle, deploy-status polling, and conflict recovery, talking to the DOM
|
|
4
|
+
* only through the {@link EditorPageUi} callback surface. `mount.ts` is the DOM
|
|
5
|
+
* binding; both the per-page editor variants and the index fallback editor go
|
|
6
|
+
* through it, so all three surfaces share this one behaviour.
|
|
7
|
+
*/
|
|
8
|
+
import type { BlockRegistry, ContentDocument, ContentSchema } from 'uncial/core';
|
|
9
|
+
import { type DeployStatusTimings, type Schedule } from './deploy-status.js';
|
|
10
|
+
import type { ForgeAdapter, SessionProvider, UncialCmsSiteConfig } from './types.js';
|
|
11
|
+
export interface StatusView {
|
|
12
|
+
text: string;
|
|
13
|
+
/** Optional commit permalink, rendered as a follow-up link. */
|
|
14
|
+
href?: string;
|
|
15
|
+
tone: 'progress' | 'success' | 'error';
|
|
16
|
+
}
|
|
17
|
+
export interface DownloadPayload {
|
|
18
|
+
filename: string;
|
|
19
|
+
content: string;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
}
|
|
22
|
+
/** The DOM-facing surface the controller drives. */
|
|
23
|
+
export interface EditorPageUi {
|
|
24
|
+
/** Render the status line (optionally with a commit link). */
|
|
25
|
+
status(view: StatusView): void;
|
|
26
|
+
/** Replace the editor's document (initial load and reload-latest). */
|
|
27
|
+
setDocument(doc: ContentDocument): void;
|
|
28
|
+
/** Enable or disable the save control. */
|
|
29
|
+
saveEnabled(enabled: boolean): void;
|
|
30
|
+
/** Show or hide the conflict recovery banner. */
|
|
31
|
+
conflictVisible(visible: boolean): void;
|
|
32
|
+
}
|
|
33
|
+
export interface EditorControllerOptions {
|
|
34
|
+
config: UncialCmsSiteConfig;
|
|
35
|
+
sourcePath: string;
|
|
36
|
+
/** Site-relative path for the deterministic commit message; defaults to sourcePath. */
|
|
37
|
+
pagePath?: string;
|
|
38
|
+
blocks: BlockRegistry;
|
|
39
|
+
schema: ContentSchema;
|
|
40
|
+
adapter: ForgeAdapter;
|
|
41
|
+
sessionProvider: SessionProvider;
|
|
42
|
+
ui: EditorPageUi;
|
|
43
|
+
/** Blocking confirm; the DOM binding passes `window.confirm`. */
|
|
44
|
+
confirm: (message: string) => boolean;
|
|
45
|
+
/** Trigger a file download of the given payload. */
|
|
46
|
+
download: (payload: DownloadPayload) => void;
|
|
47
|
+
timings?: DeployStatusTimings;
|
|
48
|
+
/**
|
|
49
|
+
* Debounced autosave: every change schedules a save this many milliseconds
|
|
50
|
+
* later, and a further change restarts the wait. Omitted leaves saving
|
|
51
|
+
* manual. A backend with no second writer — the local filesystem — is what
|
|
52
|
+
* this is for; on a forge every keystroke would become a commit.
|
|
53
|
+
*/
|
|
54
|
+
autosaveMs?: number;
|
|
55
|
+
schedule?: Schedule;
|
|
56
|
+
/** True once the owning surface has been torn down. */
|
|
57
|
+
isDestroyed?: () => boolean;
|
|
58
|
+
}
|
|
59
|
+
export interface EditorController {
|
|
60
|
+
load(): Promise<void>;
|
|
61
|
+
save(): Promise<void>;
|
|
62
|
+
/** Conflict banner action (b): discard the unsaved doc and refetch (after confirm). */
|
|
63
|
+
reloadLatest(): Promise<void>;
|
|
64
|
+
/** Conflict banner action (a): download the unsaved doc as JSON. */
|
|
65
|
+
downloadMyVersion(): void;
|
|
66
|
+
/** Close the banner, leaving content and the save button untouched. */
|
|
67
|
+
dismissConflict(): void;
|
|
68
|
+
/** Forwarded editor change events. */
|
|
69
|
+
documentChanged(doc: ContentDocument): void;
|
|
70
|
+
isDirty(): boolean;
|
|
71
|
+
/** Cancel any pending autosave and any in-flight deploy polling. */
|
|
72
|
+
stop(): void;
|
|
73
|
+
}
|
|
74
|
+
/** Basename of the JSON source, used for the conflict download filename. */
|
|
75
|
+
export declare function conflictDownloadFilename(sourcePath: string): string;
|
|
76
|
+
export declare function createEditorController(opts: EditorControllerOptions): EditorController;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { parseDocument, serializeDocument } from './document.js';
|
|
2
|
+
import { ConflictError } from './errors.js';
|
|
3
|
+
import { DEFAULT_DEPLOY_STATUS_TIMINGS, defaultSchedule, describeDeployPhase, githubCommitUrl, startDeployPolling } from './deploy-status.js';
|
|
4
|
+
import { setActiveForge } from './upload-context.js';
|
|
5
|
+
/** Basename of the JSON source, used for the conflict download filename. */
|
|
6
|
+
export function conflictDownloadFilename(sourcePath) {
|
|
7
|
+
const base = sourcePath.split('/').pop() || 'document';
|
|
8
|
+
return base.endsWith('.json') ? base : `${base}.json`;
|
|
9
|
+
}
|
|
10
|
+
export function createEditorController(opts) {
|
|
11
|
+
const { config, sourcePath, blocks, schema, adapter, sessionProvider, ui } = opts;
|
|
12
|
+
const timings = opts.timings ?? DEFAULT_DEPLOY_STATUS_TIMINGS;
|
|
13
|
+
const schedule = opts.schedule ?? defaultSchedule;
|
|
14
|
+
const destroyed = () => opts.isDestroyed?.() ?? false;
|
|
15
|
+
const branch = config.forge === 'github' ? config.branch : 'the local checkout';
|
|
16
|
+
let session = null;
|
|
17
|
+
let sha = null;
|
|
18
|
+
let currentDocument = null;
|
|
19
|
+
let dirty = false;
|
|
20
|
+
let poll = null;
|
|
21
|
+
let cancelAutosave = null;
|
|
22
|
+
let saving = false;
|
|
23
|
+
let saveAgain = false;
|
|
24
|
+
const editingStatus = () => ui.status({ tone: 'progress', text: `Editing ${sourcePath} as ${session?.user.login ?? '…'}` });
|
|
25
|
+
const stopPolling = () => {
|
|
26
|
+
poll?.cancel();
|
|
27
|
+
poll = null;
|
|
28
|
+
};
|
|
29
|
+
const cancelPendingAutosave = () => {
|
|
30
|
+
cancelAutosave?.();
|
|
31
|
+
cancelAutosave = null;
|
|
32
|
+
};
|
|
33
|
+
const startPolling = (commitSha) => {
|
|
34
|
+
const commitUrl = config.forge === 'github' ? githubCommitUrl(config.repo, commitSha) : '';
|
|
35
|
+
poll = startDeployPolling({
|
|
36
|
+
check: () => adapter.commitStatus(commitSha),
|
|
37
|
+
onPhase: (phase) => {
|
|
38
|
+
if (destroyed())
|
|
39
|
+
return;
|
|
40
|
+
const view = describeDeployPhase(phase, { branch, commitSha, commitUrl });
|
|
41
|
+
ui.status({ text: view.text, href: view.commitUrl, tone: view.tone });
|
|
42
|
+
},
|
|
43
|
+
timings,
|
|
44
|
+
schedule: opts.schedule
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const load = async () => {
|
|
48
|
+
ui.status({ tone: 'progress', text: 'Signing in…' });
|
|
49
|
+
session = await adapter.authenticate(config, sessionProvider);
|
|
50
|
+
if (destroyed())
|
|
51
|
+
return;
|
|
52
|
+
// Publish the authenticated forge so in-editor block UIs (the Image block's
|
|
53
|
+
// upload) can reach the same adapter + author. Cleared by mountEditorPage on
|
|
54
|
+
// teardown.
|
|
55
|
+
setActiveForge({
|
|
56
|
+
adapter,
|
|
57
|
+
author: { name: session.user.name, email: session.user.email },
|
|
58
|
+
config
|
|
59
|
+
});
|
|
60
|
+
ui.status({ tone: 'progress', text: 'Loading…' });
|
|
61
|
+
const file = await adapter.readFile(sourcePath);
|
|
62
|
+
if (destroyed())
|
|
63
|
+
return;
|
|
64
|
+
sha = file.sha;
|
|
65
|
+
currentDocument = parseDocument(file.content, blocks, schema);
|
|
66
|
+
ui.setDocument(currentDocument);
|
|
67
|
+
dirty = false;
|
|
68
|
+
ui.saveEnabled(true);
|
|
69
|
+
editingStatus();
|
|
70
|
+
};
|
|
71
|
+
const save = async () => {
|
|
72
|
+
if (!session || !currentDocument)
|
|
73
|
+
return;
|
|
74
|
+
// Autosave makes overlapping writes reachable in a way manual saving did
|
|
75
|
+
// not: a keystroke landing mid-write would otherwise PUT against a sha the
|
|
76
|
+
// in-flight save is about to replace. Coalesce into one follow-up save.
|
|
77
|
+
if (saving) {
|
|
78
|
+
saveAgain = true;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
saving = true;
|
|
82
|
+
cancelPendingAutosave();
|
|
83
|
+
stopPolling();
|
|
84
|
+
ui.conflictVisible(false);
|
|
85
|
+
ui.saveEnabled(false);
|
|
86
|
+
ui.status({ tone: 'progress', text: 'Saving…' });
|
|
87
|
+
try {
|
|
88
|
+
const content = serializeDocument(currentDocument, blocks, schema);
|
|
89
|
+
const result = await adapter.writeFile(sourcePath, content, {
|
|
90
|
+
message: `uncial-cms: edit ${opts.pagePath ?? sourcePath}`,
|
|
91
|
+
sha: sha ?? undefined,
|
|
92
|
+
author: { name: session.user.name, email: session.user.email }
|
|
93
|
+
});
|
|
94
|
+
sha = result.sha;
|
|
95
|
+
dirty = false;
|
|
96
|
+
startPolling(result.commitSha);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (error instanceof ConflictError) {
|
|
100
|
+
// Do NOT touch content or dirty state: the unsaved edit must survive.
|
|
101
|
+
ui.conflictVisible(true);
|
|
102
|
+
ui.status({
|
|
103
|
+
tone: 'error',
|
|
104
|
+
text: `Save conflicted — this page changed on ${branch} since you loaded it.`
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
ui.status({ tone: 'error', text: error instanceof Error ? error.message : 'Save failed.' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
saving = false;
|
|
113
|
+
if (!destroyed())
|
|
114
|
+
ui.saveEnabled(true);
|
|
115
|
+
if (saveAgain && !destroyed()) {
|
|
116
|
+
saveAgain = false;
|
|
117
|
+
void save();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const reloadLatest = async () => {
|
|
122
|
+
const proceed = opts.confirm(`Reload the latest version from ${branch}? This discards your unsaved changes` +
|
|
123
|
+
' unless you have downloaded them.');
|
|
124
|
+
if (!proceed)
|
|
125
|
+
return; // Only "Reload latest" (confirmed) replaces content + sha.
|
|
126
|
+
const file = await adapter.readFile(sourcePath);
|
|
127
|
+
if (destroyed())
|
|
128
|
+
return;
|
|
129
|
+
sha = file.sha;
|
|
130
|
+
currentDocument = parseDocument(file.content, blocks, schema);
|
|
131
|
+
ui.setDocument(currentDocument);
|
|
132
|
+
dirty = false;
|
|
133
|
+
ui.conflictVisible(false);
|
|
134
|
+
editingStatus();
|
|
135
|
+
};
|
|
136
|
+
const downloadMyVersion = () => {
|
|
137
|
+
if (!currentDocument)
|
|
138
|
+
return;
|
|
139
|
+
opts.download({
|
|
140
|
+
filename: conflictDownloadFilename(sourcePath),
|
|
141
|
+
content: serializeDocument(currentDocument, blocks, schema),
|
|
142
|
+
mimeType: 'application/json'
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
const dismissConflict = () => {
|
|
146
|
+
ui.conflictVisible(false);
|
|
147
|
+
};
|
|
148
|
+
const documentChanged = (doc) => {
|
|
149
|
+
currentDocument = doc;
|
|
150
|
+
dirty = true;
|
|
151
|
+
if (opts.autosaveMs === undefined)
|
|
152
|
+
return;
|
|
153
|
+
cancelPendingAutosave();
|
|
154
|
+
cancelAutosave = schedule(() => {
|
|
155
|
+
cancelAutosave = null;
|
|
156
|
+
void save();
|
|
157
|
+
}, opts.autosaveMs);
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
load,
|
|
161
|
+
save,
|
|
162
|
+
reloadLatest,
|
|
163
|
+
downloadMyVersion,
|
|
164
|
+
dismissConflict,
|
|
165
|
+
documentChanged,
|
|
166
|
+
isDirty: () => dirty,
|
|
167
|
+
stop: () => {
|
|
168
|
+
cancelPendingAutosave();
|
|
169
|
+
stopPolling();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CMS's editing session without a DOM: storage, autosave, deploy status and
|
|
3
|
+
* conflict recovery, driven by a host that renders the editor however it likes.
|
|
4
|
+
*
|
|
5
|
+
* `mountEditorPage` is the other door onto the same machinery, and it builds the
|
|
6
|
+
* whole surface itself — a custom element with a shadow root, seeded with the
|
|
7
|
+
* host page's stylesheets. That is the right shape for a host with no component
|
|
8
|
+
* model to speak of. It is the wrong shape for one that has Uncial's own
|
|
9
|
+
* `Editor` component available: the shadow boundary that buys style isolation
|
|
10
|
+
* costs a framework host every rule the page sets on `body`, which is where most
|
|
11
|
+
* sites put their type and their ground.
|
|
12
|
+
*
|
|
13
|
+
* So this is the same batteries, minus the surface. A Svelte, React or Vue host
|
|
14
|
+
* renders `Editor` itself, in its own tree and its own cascade, and hands the
|
|
15
|
+
* session four callbacks and each edit as it happens.
|
|
16
|
+
*
|
|
17
|
+
* Reach it at `uncial-cms/session` rather than through the package root. The
|
|
18
|
+
* root exports `mountEditorPage` too, and importing that pulls in the custom
|
|
19
|
+
* element, its shadow-root machinery and the editor's chrome stylesheet — which
|
|
20
|
+
* a host rendering its own surface neither wants loaded nor wants arriving after
|
|
21
|
+
* its own corrections to that stylesheet.
|
|
22
|
+
*/
|
|
23
|
+
import { type EditorController, type EditorPageUi } from './editor-controller.js';
|
|
24
|
+
import type { BlockRegistry, ContentSchema } from 'uncial/core';
|
|
25
|
+
import type { ForgeAdapter, SessionProvider, UncialCmsSiteConfig } from './types.js';
|
|
26
|
+
export type { DownloadPayload, EditorController, EditorPageUi, StatusView } from './editor-controller.js';
|
|
27
|
+
export interface CreateEditorSessionOptions {
|
|
28
|
+
config: UncialCmsSiteConfig;
|
|
29
|
+
/** Repo-root-relative path of the JSON document being edited. */
|
|
30
|
+
sourcePath: string;
|
|
31
|
+
/**
|
|
32
|
+
* Site-relative page path, used in the deterministic commit message
|
|
33
|
+
* `uncial-cms: edit <path>`. Falls back to `sourcePath`.
|
|
34
|
+
*/
|
|
35
|
+
pagePath?: string;
|
|
36
|
+
blocks: BlockRegistry;
|
|
37
|
+
schema: ContentSchema;
|
|
38
|
+
/** The four things the session needs the host to do to its own surface. */
|
|
39
|
+
ui: EditorPageUi;
|
|
40
|
+
/**
|
|
41
|
+
* Debounced autosave in milliseconds. Omitted keeps saving manual, which is
|
|
42
|
+
* what a forge backend wants — there, every keystroke would be a commit.
|
|
43
|
+
*/
|
|
44
|
+
autosaveMs?: number;
|
|
45
|
+
/** Defaults to the provider the configured forge implies. */
|
|
46
|
+
sessionProvider?: SessionProvider;
|
|
47
|
+
/** Defaults to `window.confirm`. */
|
|
48
|
+
confirm?: (message: string) => boolean;
|
|
49
|
+
/** Defaults to an anchor-driven blob download. */
|
|
50
|
+
download?: (payload: {
|
|
51
|
+
filename: string;
|
|
52
|
+
content: string;
|
|
53
|
+
mimeType: string;
|
|
54
|
+
}) => void;
|
|
55
|
+
/** True once the host's surface has been torn down. */
|
|
56
|
+
isDestroyed?: () => boolean;
|
|
57
|
+
}
|
|
58
|
+
export declare function forgeAdapter(config: UncialCmsSiteConfig): ForgeAdapter;
|
|
59
|
+
export declare function defaultSessionProvider(config: UncialCmsSiteConfig): SessionProvider;
|
|
60
|
+
export declare function createEditorSession(opts: CreateEditorSessionOptions): EditorController;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CMS's editing session without a DOM: storage, autosave, deploy status and
|
|
3
|
+
* conflict recovery, driven by a host that renders the editor however it likes.
|
|
4
|
+
*
|
|
5
|
+
* `mountEditorPage` is the other door onto the same machinery, and it builds the
|
|
6
|
+
* whole surface itself — a custom element with a shadow root, seeded with the
|
|
7
|
+
* host page's stylesheets. That is the right shape for a host with no component
|
|
8
|
+
* model to speak of. It is the wrong shape for one that has Uncial's own
|
|
9
|
+
* `Editor` component available: the shadow boundary that buys style isolation
|
|
10
|
+
* costs a framework host every rule the page sets on `body`, which is where most
|
|
11
|
+
* sites put their type and their ground.
|
|
12
|
+
*
|
|
13
|
+
* So this is the same batteries, minus the surface. A Svelte, React or Vue host
|
|
14
|
+
* renders `Editor` itself, in its own tree and its own cascade, and hands the
|
|
15
|
+
* session four callbacks and each edit as it happens.
|
|
16
|
+
*
|
|
17
|
+
* Reach it at `uncial-cms/session` rather than through the package root. The
|
|
18
|
+
* root exports `mountEditorPage` too, and importing that pulls in the custom
|
|
19
|
+
* element, its shadow-root machinery and the editor's chrome stylesheet — which
|
|
20
|
+
* a host rendering its own surface neither wants loaded nor wants arriving after
|
|
21
|
+
* its own corrections to that stylesheet.
|
|
22
|
+
*/
|
|
23
|
+
import { createEditorController } from './editor-controller.js';
|
|
24
|
+
import { createGitHubAdapter, popupSessionProvider } from './github/index.js';
|
|
25
|
+
import { createLocalAdapter } from './local/adapter.js';
|
|
26
|
+
import { localSessionProvider } from './local/session.js';
|
|
27
|
+
export function forgeAdapter(config) {
|
|
28
|
+
if (config.forge === 'github')
|
|
29
|
+
return createGitHubAdapter();
|
|
30
|
+
if (config.forge === 'local')
|
|
31
|
+
return createLocalAdapter();
|
|
32
|
+
throw new Error(`Unknown forge "${config.forge}".`);
|
|
33
|
+
}
|
|
34
|
+
export function defaultSessionProvider(config) {
|
|
35
|
+
return config.forge === 'local' ? localSessionProvider : popupSessionProvider;
|
|
36
|
+
}
|
|
37
|
+
function triggerDownload(payload) {
|
|
38
|
+
const blob = new Blob([payload.content], { type: payload.mimeType });
|
|
39
|
+
const url = URL.createObjectURL(blob);
|
|
40
|
+
const anchor = document.createElement('a');
|
|
41
|
+
anchor.href = url;
|
|
42
|
+
anchor.download = payload.filename;
|
|
43
|
+
document.body.append(anchor);
|
|
44
|
+
anchor.click();
|
|
45
|
+
anchor.remove();
|
|
46
|
+
URL.revokeObjectURL(url);
|
|
47
|
+
}
|
|
48
|
+
export function createEditorSession(opts) {
|
|
49
|
+
return createEditorController({
|
|
50
|
+
config: opts.config,
|
|
51
|
+
sourcePath: opts.sourcePath,
|
|
52
|
+
pagePath: opts.pagePath,
|
|
53
|
+
blocks: opts.blocks,
|
|
54
|
+
schema: opts.schema,
|
|
55
|
+
adapter: forgeAdapter(opts.config),
|
|
56
|
+
sessionProvider: opts.sessionProvider ?? defaultSessionProvider(opts.config),
|
|
57
|
+
ui: opts.ui,
|
|
58
|
+
confirm: opts.confirm ?? ((message) => window.confirm(message)),
|
|
59
|
+
download: opts.download ?? triggerDownload,
|
|
60
|
+
autosaveMs: opts.autosaveMs,
|
|
61
|
+
isDestroyed: opts.isDestroyed
|
|
62
|
+
});
|
|
63
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Thrown ONLY when a write is rejected because the provided sha is stale. */
|
|
2
|
+
export declare class ConflictError extends Error {
|
|
3
|
+
constructor(message?: string);
|
|
4
|
+
}
|
|
5
|
+
/** Thrown when a forge read targets a path that does not exist (404). */
|
|
6
|
+
export declare class NotFoundError extends Error {
|
|
7
|
+
constructor(message?: string);
|
|
8
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Thrown ONLY when a write is rejected because the provided sha is stale. */
|
|
2
|
+
export class ConflictError extends Error {
|
|
3
|
+
constructor(message = 'The document changed on the server since it was loaded.') {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'ConflictError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Thrown when a forge read targets a path that does not exist (404). */
|
|
9
|
+
export class NotFoundError extends Error {
|
|
10
|
+
constructor(message = 'The requested file does not exist.') {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'NotFoundError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { UploadAssetFile } from './index-actions.js';
|
|
2
|
+
export interface EncodableImage {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
close?: () => void;
|
|
6
|
+
}
|
|
7
|
+
/** Decode/encode seam, injected so the descent is testable without a canvas. */
|
|
8
|
+
export interface ImageEncoder {
|
|
9
|
+
decode: (file: Blob) => Promise<EncodableImage>;
|
|
10
|
+
encode: (image: EncodableImage, width: number, height: number, quality: number) => Promise<Blob>;
|
|
11
|
+
}
|
|
12
|
+
export interface FitOptions {
|
|
13
|
+
/** Byte ceiling the result must come under; defaults to the Contents API cap. */
|
|
14
|
+
maxBytes?: number;
|
|
15
|
+
/** Longest-edge ceiling for the re-encoded image. */
|
|
16
|
+
maxEdge?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface FittedImage extends UploadAssetFile {
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Prepare `file` for upload under `maxBytes`. Bytes that already fit inside
|
|
24
|
+
* both ceilings pass through untouched, keeping their original filename and
|
|
25
|
+
* content type; anything else comes back as WebP named `<stem>.webp`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function fitImage(file: Blob & {
|
|
28
|
+
name?: string;
|
|
29
|
+
}, opts?: FitOptions & {
|
|
30
|
+
encoder?: ImageEncoder;
|
|
31
|
+
}): Promise<FittedImage>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-encode an oversize image so it fits a size-capped forge. The Contents API
|
|
3
|
+
* refuses anything over ~1 MB, which a photograph off a phone always is, so the
|
|
4
|
+
* editor's upload path re-encodes to WebP within a bounded longest edge and
|
|
5
|
+
* steps quality — then dimensions — down until the bytes fit.
|
|
6
|
+
*/
|
|
7
|
+
import { MAX_CONTENT_BYTES } from './constants.js';
|
|
8
|
+
const DEFAULT_MAX_EDGE = 2000;
|
|
9
|
+
const INITIAL_QUALITY = 0.82;
|
|
10
|
+
const MIN_QUALITY = 0.5;
|
|
11
|
+
const QUALITY_STEP = 0.1;
|
|
12
|
+
const SIZE_STEP = 0.85;
|
|
13
|
+
const MIN_EDGE = 320;
|
|
14
|
+
const browserEncoder = {
|
|
15
|
+
decode: (file) => createImageBitmap(file),
|
|
16
|
+
encode: async (image, width, height, quality) => {
|
|
17
|
+
const canvas = document.createElement('canvas');
|
|
18
|
+
canvas.width = width;
|
|
19
|
+
canvas.height = height;
|
|
20
|
+
const context = canvas.getContext('2d');
|
|
21
|
+
if (!context)
|
|
22
|
+
throw new Error('This browser cannot prepare images for upload.');
|
|
23
|
+
context.drawImage(image, 0, 0, width, height);
|
|
24
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', quality));
|
|
25
|
+
if (!blob || blob.type !== 'image/webp') {
|
|
26
|
+
throw new Error('This browser cannot encode WebP images for upload.');
|
|
27
|
+
}
|
|
28
|
+
return blob;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
function dimensionsWithin(width, height, longestEdge) {
|
|
32
|
+
const sourceEdge = Math.max(width, height);
|
|
33
|
+
if (sourceEdge <= longestEdge)
|
|
34
|
+
return [width, height];
|
|
35
|
+
const scale = longestEdge / sourceEdge;
|
|
36
|
+
return [Math.max(1, Math.round(width * scale)), Math.max(1, Math.round(height * scale))];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Prepare `file` for upload under `maxBytes`. Bytes that already fit inside
|
|
40
|
+
* both ceilings pass through untouched, keeping their original filename and
|
|
41
|
+
* content type; anything else comes back as WebP named `<stem>.webp`.
|
|
42
|
+
*/
|
|
43
|
+
export async function fitImage(file, opts = {}) {
|
|
44
|
+
const maxBytes = opts.maxBytes ?? MAX_CONTENT_BYTES;
|
|
45
|
+
const maxEdge = opts.maxEdge ?? DEFAULT_MAX_EDGE;
|
|
46
|
+
const encoder = opts.encoder ?? browserEncoder;
|
|
47
|
+
const filename = file.name || 'image';
|
|
48
|
+
const image = await encoder.decode(file);
|
|
49
|
+
try {
|
|
50
|
+
if (file.size <= maxBytes && Math.max(image.width, image.height) <= maxEdge) {
|
|
51
|
+
return {
|
|
52
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
53
|
+
filename,
|
|
54
|
+
contentType: file.type || 'application/octet-stream',
|
|
55
|
+
width: image.width,
|
|
56
|
+
height: image.height
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
let [width, height] = dimensionsWithin(image.width, image.height, maxEdge);
|
|
60
|
+
let quality = INITIAL_QUALITY;
|
|
61
|
+
while (true) {
|
|
62
|
+
const blob = await encoder.encode(image, width, height, quality);
|
|
63
|
+
if (blob.size <= maxBytes) {
|
|
64
|
+
const stem = filename.replace(/\.[^.]*$/, '') || 'image';
|
|
65
|
+
return {
|
|
66
|
+
bytes: new Uint8Array(await blob.arrayBuffer()),
|
|
67
|
+
filename: `${stem}.webp`,
|
|
68
|
+
contentType: 'image/webp',
|
|
69
|
+
width,
|
|
70
|
+
height
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (quality - QUALITY_STEP >= MIN_QUALITY) {
|
|
74
|
+
quality -= QUALITY_STEP;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const nextEdge = Math.floor(Math.max(width, height) * SIZE_STEP);
|
|
78
|
+
if (nextEdge < MIN_EDGE)
|
|
79
|
+
break;
|
|
80
|
+
[width, height] = dimensionsWithin(width, height, nextEdge);
|
|
81
|
+
quality = INITIAL_QUALITY;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
image.close?.();
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`Image "${filename}" could not be reduced below the ${maxBytes}-byte upload limit.`);
|
|
88
|
+
}
|