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,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/uncial/` site-index runtime (PRD §6.6, D2): list/create/delete pages and
|
|
3
|
+
* the hash-routed fallback editor (`#/<path>/`). Framework-agnostic plain DOM,
|
|
4
|
+
* same style as mountEditorPage — the SvelteKit index route is just a shell
|
|
5
|
+
* that calls this.
|
|
6
|
+
*/
|
|
7
|
+
import { createPage, deletePage, listPages } from './index-actions.js';
|
|
8
|
+
import { mountEditorPage } from './mount.js';
|
|
9
|
+
import { defaultMapPathToSource, hashForPagePath, pagePathFromHash, validatePagePath } from './paths/index.js';
|
|
10
|
+
import { createGitHubAdapter, popupSessionProvider } from './github/index.js';
|
|
11
|
+
import { createLocalAdapter } from './local/adapter.js';
|
|
12
|
+
import { localSessionProvider } from './local/session.js';
|
|
13
|
+
import { UNCIAL_CMS_RUNTIME_SENTINEL } from './sentinel.js';
|
|
14
|
+
function createAdapter(config) {
|
|
15
|
+
if (config.forge === 'github')
|
|
16
|
+
return createGitHubAdapter();
|
|
17
|
+
if (config.forge === 'local')
|
|
18
|
+
return createLocalAdapter();
|
|
19
|
+
throw new Error(`Unknown forge "${config.forge}".`);
|
|
20
|
+
}
|
|
21
|
+
export function mountIndexPage(target, opts) {
|
|
22
|
+
const { config, basePath = '' } = opts;
|
|
23
|
+
const sessionProvider = opts.sessionProvider ?? (config.forge === 'local' ? localSessionProvider : popupSessionProvider);
|
|
24
|
+
const locationLabel = config.forge === 'github' ? `${config.repo}@${config.branch}` : 'the local checkout';
|
|
25
|
+
const branchLabel = config.forge === 'github' ? config.branch : 'the local checkout';
|
|
26
|
+
const mapPathToSource = opts.mapPathToSource ?? ((path) => defaultMapPathToSource(path, config.contentDir));
|
|
27
|
+
const root = document.createElement('div');
|
|
28
|
+
root.className = 'uncial-cms-index';
|
|
29
|
+
root.dataset.uncialCmsRuntime = UNCIAL_CMS_RUNTIME_SENTINEL;
|
|
30
|
+
const status = document.createElement('p');
|
|
31
|
+
status.className = 'uncial-cms-status';
|
|
32
|
+
status.setAttribute('role', 'status');
|
|
33
|
+
const listView = document.createElement('section');
|
|
34
|
+
listView.className = 'uncial-cms-index-list';
|
|
35
|
+
const editorView = document.createElement('section');
|
|
36
|
+
editorView.className = 'uncial-cms-fallback-editor';
|
|
37
|
+
editorView.hidden = true;
|
|
38
|
+
root.append(status, listView, editorView);
|
|
39
|
+
target.append(root);
|
|
40
|
+
let destroyed = false;
|
|
41
|
+
let adapter = null;
|
|
42
|
+
let session = null;
|
|
43
|
+
let editorHandle = null;
|
|
44
|
+
let currentHash = location.hash;
|
|
45
|
+
let revertingHash = false;
|
|
46
|
+
const setStatus = (text) => {
|
|
47
|
+
status.textContent = text;
|
|
48
|
+
};
|
|
49
|
+
const livePageUrl = (pagePath) => `${basePath}/${pagePath === '' ? '' : `${pagePath}/`}`;
|
|
50
|
+
const renderList = (pages) => {
|
|
51
|
+
listView.replaceChildren();
|
|
52
|
+
const heading = document.createElement('h2');
|
|
53
|
+
heading.textContent = 'Pages';
|
|
54
|
+
const list = document.createElement('ul');
|
|
55
|
+
for (const page of pages) {
|
|
56
|
+
const item = document.createElement('li');
|
|
57
|
+
const live = document.createElement('a');
|
|
58
|
+
live.href = livePageUrl(page.pagePath);
|
|
59
|
+
live.textContent = page.pagePath === '' ? '(site root)' : `/${page.pagePath}/`;
|
|
60
|
+
// Always link the fallback editor — it works whether or not the page's
|
|
61
|
+
// static variant has deployed yet.
|
|
62
|
+
const edit = document.createElement('a');
|
|
63
|
+
edit.href = hashForPagePath(page.pagePath);
|
|
64
|
+
edit.textContent = 'Edit';
|
|
65
|
+
const del = document.createElement('button');
|
|
66
|
+
del.type = 'button';
|
|
67
|
+
del.textContent = 'Delete';
|
|
68
|
+
del.addEventListener('click', () => void onDelete(page));
|
|
69
|
+
item.append(live, ' ', edit, ' ', del);
|
|
70
|
+
list.append(item);
|
|
71
|
+
}
|
|
72
|
+
const form = document.createElement('form');
|
|
73
|
+
form.className = 'uncial-cms-create';
|
|
74
|
+
const label = document.createElement('label');
|
|
75
|
+
label.textContent = 'New page path ';
|
|
76
|
+
const input = document.createElement('input');
|
|
77
|
+
input.name = 'path';
|
|
78
|
+
input.placeholder = 'team/new-page';
|
|
79
|
+
label.append(input);
|
|
80
|
+
const submit = document.createElement('button');
|
|
81
|
+
submit.type = 'submit';
|
|
82
|
+
submit.textContent = 'Create page';
|
|
83
|
+
const message = document.createElement('p');
|
|
84
|
+
message.className = 'uncial-cms-create-message';
|
|
85
|
+
message.setAttribute('role', 'alert');
|
|
86
|
+
message.hidden = true;
|
|
87
|
+
form.append(label, ' ', submit, message);
|
|
88
|
+
form.addEventListener('submit', (event) => {
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
void onCreate(input.value, message, submit);
|
|
91
|
+
});
|
|
92
|
+
listView.append(heading, list, form);
|
|
93
|
+
};
|
|
94
|
+
const refreshList = async () => {
|
|
95
|
+
if (!adapter)
|
|
96
|
+
return;
|
|
97
|
+
setStatus('Loading pages…');
|
|
98
|
+
const pages = await listPages(adapter, config.contentDir, opts.mapSourceToPath);
|
|
99
|
+
if (destroyed)
|
|
100
|
+
return;
|
|
101
|
+
renderList(pages);
|
|
102
|
+
setStatus(`Editing ${locationLabel} as ${session.user.login}`);
|
|
103
|
+
};
|
|
104
|
+
const onCreate = async (raw, message, submit) => {
|
|
105
|
+
const result = validatePagePath(raw);
|
|
106
|
+
if (!result.ok) {
|
|
107
|
+
message.hidden = false;
|
|
108
|
+
message.textContent = result.message;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!adapter || !session)
|
|
112
|
+
return;
|
|
113
|
+
message.hidden = true;
|
|
114
|
+
submit.disabled = true;
|
|
115
|
+
try {
|
|
116
|
+
await createPage({ adapter, blocks: opts.blocks, schema: opts.schema, author: session.user }, { pagePath: result.path, sourcePath: mapPathToSource(result.path) });
|
|
117
|
+
// Open the fallback editor immediately — the static variant only
|
|
118
|
+
// appears after the next deploy.
|
|
119
|
+
location.hash = hashForPagePath(result.path);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
message.hidden = false;
|
|
123
|
+
message.textContent = error instanceof Error ? error.message : 'Create failed.';
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
submit.disabled = false;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const onDelete = async (page) => {
|
|
130
|
+
if (!adapter)
|
|
131
|
+
return;
|
|
132
|
+
const confirmed = window.confirm(`Delete ${page.sourcePath} from ${branchLabel}? This commits the deletion immediately.`);
|
|
133
|
+
if (!confirmed)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
await deletePage(adapter, page);
|
|
137
|
+
await refreshList();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
setStatus(error instanceof Error ? error.message : 'Delete failed.');
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const closeEditor = () => {
|
|
144
|
+
editorHandle?.destroy();
|
|
145
|
+
editorHandle = null;
|
|
146
|
+
editorView.replaceChildren();
|
|
147
|
+
editorView.hidden = true;
|
|
148
|
+
listView.hidden = false;
|
|
149
|
+
};
|
|
150
|
+
const openEditor = (pagePath) => {
|
|
151
|
+
closeEditor();
|
|
152
|
+
listView.hidden = true;
|
|
153
|
+
editorView.hidden = false;
|
|
154
|
+
const back = document.createElement('a');
|
|
155
|
+
back.href = '#';
|
|
156
|
+
back.textContent = '← Back to index';
|
|
157
|
+
const heading = document.createElement('h2');
|
|
158
|
+
heading.textContent = pagePath === '' ? 'Editing site root' : `Editing /${pagePath}/`;
|
|
159
|
+
const mountTarget = document.createElement('div');
|
|
160
|
+
editorView.append(back, heading, mountTarget);
|
|
161
|
+
editorHandle = mountEditorPage(mountTarget, {
|
|
162
|
+
config,
|
|
163
|
+
sourcePath: mapPathToSource(pagePath),
|
|
164
|
+
pagePath,
|
|
165
|
+
blocks: opts.blocks,
|
|
166
|
+
schema: opts.schema,
|
|
167
|
+
sessionProvider,
|
|
168
|
+
editorStylesheets: opts.editorStylesheets
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
const applyHash = () => {
|
|
172
|
+
const pagePath = pagePathFromHash(location.hash);
|
|
173
|
+
if (pagePath === null) {
|
|
174
|
+
closeEditor();
|
|
175
|
+
void refreshList().catch((error) => {
|
|
176
|
+
setStatus(error instanceof Error ? error.message : 'Failed to list pages.');
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
openEditor(pagePath);
|
|
181
|
+
}
|
|
182
|
+
currentHash = location.hash;
|
|
183
|
+
};
|
|
184
|
+
const onHashChange = () => {
|
|
185
|
+
if (revertingHash) {
|
|
186
|
+
revertingHash = false;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (editorHandle?.isDirty() && !window.confirm('Discard unsaved changes?')) {
|
|
190
|
+
revertingHash = true;
|
|
191
|
+
location.hash = currentHash;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
applyHash();
|
|
195
|
+
};
|
|
196
|
+
const onBeforeUnload = (event) => {
|
|
197
|
+
if (editorHandle?.isDirty())
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
};
|
|
200
|
+
window.addEventListener('hashchange', onHashChange);
|
|
201
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
202
|
+
const load = async () => {
|
|
203
|
+
setStatus('Signing in…');
|
|
204
|
+
adapter = createAdapter(config);
|
|
205
|
+
session = await adapter.authenticate(config, sessionProvider);
|
|
206
|
+
if (destroyed)
|
|
207
|
+
return;
|
|
208
|
+
applyHash();
|
|
209
|
+
};
|
|
210
|
+
void load().catch((error) => {
|
|
211
|
+
if (destroyed)
|
|
212
|
+
return;
|
|
213
|
+
setStatus(error instanceof Error ? error.message : 'Failed to load the site index.');
|
|
214
|
+
});
|
|
215
|
+
return {
|
|
216
|
+
destroy() {
|
|
217
|
+
destroyed = true;
|
|
218
|
+
window.removeEventListener('hashchange', onHashChange);
|
|
219
|
+
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
220
|
+
editorHandle?.destroy();
|
|
221
|
+
root.remove();
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type { ForgeAdapter, ForgeSession, GitHubSiteConfig, SessionProvider, UncialCmsSiteConfig } from './types.js';
|
|
2
|
+
export { defineSite, DEFAULT_APP_SLUG, DEFAULT_AUTH_WORKER_URL, type Site, type SiteOptions } from './define-site.js';
|
|
3
|
+
export { ConflictError, NotFoundError } from './errors.js';
|
|
4
|
+
export { UNCIAL_CMS_RUNTIME_SENTINEL } from './sentinel.js';
|
|
5
|
+
export { mountEditorPage, type MountEditorPageOptions } from './mount.js';
|
|
6
|
+
export { createEditorSession, defaultSessionProvider, forgeAdapter, type CreateEditorSessionOptions } from './editor-session.js';
|
|
7
|
+
export { createEditorController, conflictDownloadFilename, type DownloadPayload, type EditorController, type EditorControllerOptions, type EditorPageUi, type StatusView } from './editor-controller.js';
|
|
8
|
+
export { mountIndexPage, type MountIndexPageOptions } from './index-page.js';
|
|
9
|
+
export { createPage, deletePage, listPages, uploadAsset, uploadImageAsset, type PageRef, type UploadAssetFile, type UploadAssetOptions, type UploadAssetResult, type UploadImageAssetOptions } from './index-actions.js';
|
|
10
|
+
export { fitImage, type FitOptions, type FittedImage, type ImageEncoder, type EncodableImage } from './fit-image.js';
|
|
11
|
+
export { servedUrl } from './served-url.js';
|
|
12
|
+
export { MAX_CONTENT_BYTES } from './constants.js';
|
|
13
|
+
export { hashForPagePath, pagePathFromHash, validatePagePath } from './paths/index.js';
|
|
14
|
+
export { parseDocument, serializeDocument, type Blocks } from './document.js';
|
|
15
|
+
export { patSessionProvider, popupSessionProvider } from './github/index.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { defineSite, DEFAULT_APP_SLUG, DEFAULT_AUTH_WORKER_URL } from './define-site.js';
|
|
2
|
+
export { ConflictError, NotFoundError } from './errors.js';
|
|
3
|
+
export { UNCIAL_CMS_RUNTIME_SENTINEL } from './sentinel.js';
|
|
4
|
+
export { mountEditorPage } from './mount.js';
|
|
5
|
+
/*
|
|
6
|
+
* The same editing session without a surface, for a host that renders Uncial's
|
|
7
|
+
* own `Editor` component itself. `mountEditorPage` builds a shadow root and is
|
|
8
|
+
* the right door for a host with no component model; a framework host wants its
|
|
9
|
+
* editor in its own tree and its own cascade, and only wants the storage,
|
|
10
|
+
* autosave, deploy status and conflict recovery from here.
|
|
11
|
+
*/
|
|
12
|
+
export { createEditorSession, defaultSessionProvider, forgeAdapter } from './editor-session.js';
|
|
13
|
+
export { createEditorController, conflictDownloadFilename } from './editor-controller.js';
|
|
14
|
+
export { mountIndexPage } from './index-page.js';
|
|
15
|
+
export { createPage, deletePage, listPages, uploadAsset, uploadImageAsset } from './index-actions.js';
|
|
16
|
+
export { fitImage } from './fit-image.js';
|
|
17
|
+
export { servedUrl } from './served-url.js';
|
|
18
|
+
export { MAX_CONTENT_BYTES } from './constants.js';
|
|
19
|
+
export { hashForPagePath, pagePathFromHash, validatePagePath } from './paths/index.js';
|
|
20
|
+
export { parseDocument, serializeDocument } from './document.js';
|
|
21
|
+
export { patSessionProvider, popupSessionProvider } from './github/index.js';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { bytesToBase64 } from '../base64.js';
|
|
2
|
+
import { ConflictError, NotFoundError } from '../errors.js';
|
|
3
|
+
import { LOCAL_API_PATH } from './constants.js';
|
|
4
|
+
function encodePath(path) {
|
|
5
|
+
return path.split('/').map(encodeURIComponent).join('/');
|
|
6
|
+
}
|
|
7
|
+
class LocalAdapter {
|
|
8
|
+
#config = null;
|
|
9
|
+
async authenticate(config, provider) {
|
|
10
|
+
if (config.forge !== 'local') {
|
|
11
|
+
throw new Error('Local adapter requires a local site configuration.');
|
|
12
|
+
}
|
|
13
|
+
this.#config = config;
|
|
14
|
+
return provider(config);
|
|
15
|
+
}
|
|
16
|
+
async readFile(path) {
|
|
17
|
+
return this.#request('POST', `files/${this.#path(path)}`, {});
|
|
18
|
+
}
|
|
19
|
+
async writeFile(path, content, opts) {
|
|
20
|
+
return this.#request('PUT', `files/${this.#path(path)}`, {
|
|
21
|
+
content: typeof content === 'string' ? content : bytesToBase64(content),
|
|
22
|
+
...(typeof content === 'string' ? {} : { encoding: 'base64' }),
|
|
23
|
+
// Absent = create or replace outright, as on the GitHub adapter.
|
|
24
|
+
...(opts.sha === undefined ? {} : { sha: opts.sha })
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async deleteFile(path, _opts) {
|
|
28
|
+
await this.#request('DELETE', `files/${this.#path(path)}`, {});
|
|
29
|
+
}
|
|
30
|
+
async listDir(path) {
|
|
31
|
+
// The plugin roots at the repository, so the paths it reports are already
|
|
32
|
+
// the repo-root-relative ones every forge adapter speaks.
|
|
33
|
+
const response = await this.#request('POST', `dirs/${this.#path(path)}`, {});
|
|
34
|
+
return response.entries;
|
|
35
|
+
}
|
|
36
|
+
async commitStatus(_commitSha) {
|
|
37
|
+
return 'success';
|
|
38
|
+
}
|
|
39
|
+
/** Repo-root-relative, exactly as the GitHub adapter sends it. */
|
|
40
|
+
#path(path) {
|
|
41
|
+
if (!this.#config)
|
|
42
|
+
throw new Error('Local adapter is not authenticated; call authenticate() first.');
|
|
43
|
+
return encodePath(path.replace(/^\/+|\/+$/g, ''));
|
|
44
|
+
}
|
|
45
|
+
async #request(method, path, body) {
|
|
46
|
+
const response = await fetch(new URL(`${LOCAL_API_PATH}/${path}`, location.origin), {
|
|
47
|
+
method,
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify(body)
|
|
50
|
+
});
|
|
51
|
+
if (response.ok)
|
|
52
|
+
return (await response.json());
|
|
53
|
+
const error = (await response.json().catch(() => ({})));
|
|
54
|
+
if (response.status === 404)
|
|
55
|
+
throw new NotFoundError(error.error ?? 'File not found.');
|
|
56
|
+
if (response.status === 409)
|
|
57
|
+
throw new ConflictError(error.error);
|
|
58
|
+
throw new Error(error.error ?? `Local request failed (${response.status}).`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function createLocalAdapter() {
|
|
62
|
+
return new LocalAdapter();
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const LOCAL_API_PATH = "/__uncial-cms/local";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const LOCAL_API_PATH = '/__uncial-cms/local';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const localSession = {
|
|
2
|
+
token: 'local',
|
|
3
|
+
expiresAt: null,
|
|
4
|
+
repo: 'local',
|
|
5
|
+
user: { login: 'local', name: 'Local editor', email: 'local@localhost' }
|
|
6
|
+
};
|
|
7
|
+
export const localSessionProvider = async (config) => {
|
|
8
|
+
if (config.forge !== 'local') {
|
|
9
|
+
throw new Error('The local session provider requires a local site configuration.');
|
|
10
|
+
}
|
|
11
|
+
return localSession;
|
|
12
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
export interface LocalVitePluginOptions {
|
|
3
|
+
/** Repository root: every path the endpoint receives resolves against it. */
|
|
4
|
+
root: string;
|
|
5
|
+
/** Repo-root-relative directories a request may address, as the site declares them. */
|
|
6
|
+
permittedRoots: string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function createLocalVitePlugin(options: LocalVitePluginOptions): Plugin;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { MAX_CONTENT_BYTES } from '../constants.js';
|
|
5
|
+
import { LOCAL_API_PATH } from './constants.js';
|
|
6
|
+
/**
|
|
7
|
+
* A base64 body spends four bytes on every three of payload, and the JSON
|
|
8
|
+
* envelope adds its keys on top, so the request cap has to clear the envelope of
|
|
9
|
+
* a `MAX_CONTENT_BYTES` upload — which is exactly the size `fitImage` targets.
|
|
10
|
+
* The decoded cap in {@link handleWrite} is the one that enforces the limit.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_REQUEST_BYTES = Math.ceil(MAX_CONTENT_BYTES / 3) * 4 + 1024;
|
|
13
|
+
class HttpError extends Error {
|
|
14
|
+
status;
|
|
15
|
+
constructor(status, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function isRecord(value) {
|
|
21
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
function sendJson(response, status, body) {
|
|
24
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
25
|
+
response.end(JSON.stringify(body));
|
|
26
|
+
}
|
|
27
|
+
async function readJson(request) {
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let size = 0;
|
|
30
|
+
for await (const chunk of request) {
|
|
31
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
32
|
+
size += bytes.byteLength;
|
|
33
|
+
if (size > MAX_REQUEST_BYTES) {
|
|
34
|
+
throw new HttpError(413, `Request body exceeds ${MAX_REQUEST_BYTES} bytes.`);
|
|
35
|
+
}
|
|
36
|
+
chunks.push(bytes);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new HttpError(400, 'Request body must be valid JSON.');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function resolveRoots({ root, permittedRoots }) {
|
|
46
|
+
const repositoryRoot = resolve(root);
|
|
47
|
+
return {
|
|
48
|
+
root: repositoryRoot,
|
|
49
|
+
permitted: permittedRoots.map((declared) => ({
|
|
50
|
+
declared,
|
|
51
|
+
absolute: resolve(repositoryRoot, declared)
|
|
52
|
+
}))
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function contains(directory, target) {
|
|
56
|
+
const from = relative(directory, target);
|
|
57
|
+
return from === '' || (!from.startsWith(`..${sep}`) && from !== '..' && !isAbsolute(from));
|
|
58
|
+
}
|
|
59
|
+
function repositoryPath(roots, encodedPath) {
|
|
60
|
+
let path;
|
|
61
|
+
try {
|
|
62
|
+
path = decodeURIComponent(encodedPath);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
throw new HttpError(400, 'Path is not valid URL encoding.');
|
|
66
|
+
}
|
|
67
|
+
const target = resolve(roots.root, path);
|
|
68
|
+
if (!roots.permitted.some((permitted) => contains(permitted.absolute, target))) {
|
|
69
|
+
const names = roots.permitted.map((permitted) => permitted.declared).join(', ');
|
|
70
|
+
throw new HttpError(403, `Path must stay within ${names}.`);
|
|
71
|
+
}
|
|
72
|
+
return target;
|
|
73
|
+
}
|
|
74
|
+
async function handleRead(request, response, roots, encodedPath) {
|
|
75
|
+
await readJson(request);
|
|
76
|
+
const target = repositoryPath(roots, encodedPath);
|
|
77
|
+
let content;
|
|
78
|
+
try {
|
|
79
|
+
content = await readFile(target);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') {
|
|
83
|
+
throw new HttpError(404, 'File not found.');
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
if (content.byteLength > MAX_CONTENT_BYTES) {
|
|
88
|
+
throw new HttpError(413, `File exceeds ${MAX_CONTENT_BYTES} bytes.`);
|
|
89
|
+
}
|
|
90
|
+
sendJson(response, 200, {
|
|
91
|
+
content: content.toString('utf8'),
|
|
92
|
+
sha: createHash('sha256').update(content).digest('hex')
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async function handleWrite(request, response, roots, encodedPath) {
|
|
96
|
+
const body = await readJson(request);
|
|
97
|
+
if (!isRecord(body) || typeof body.content !== 'string') {
|
|
98
|
+
throw new HttpError(400, 'Write request must contain a string content property.');
|
|
99
|
+
}
|
|
100
|
+
if (body.encoding !== undefined && body.encoding !== 'base64') {
|
|
101
|
+
throw new HttpError(400, 'Write encoding must be base64 when provided.');
|
|
102
|
+
}
|
|
103
|
+
if (body.sha !== undefined && typeof body.sha !== 'string') {
|
|
104
|
+
throw new HttpError(400, 'Write sha must be a string when provided.');
|
|
105
|
+
}
|
|
106
|
+
const target = repositoryPath(roots, encodedPath);
|
|
107
|
+
const content = Buffer.from(body.content, body.encoding === 'base64' ? 'base64' : 'utf8');
|
|
108
|
+
if (content.byteLength > MAX_CONTENT_BYTES) {
|
|
109
|
+
throw new HttpError(413, `Content exceeds ${MAX_CONTENT_BYTES} bytes.`);
|
|
110
|
+
}
|
|
111
|
+
// A sha means "replace the revision I read"; the GitHub Contents API answers
|
|
112
|
+
// a stale one with 409, and the editor's conflict recovery is written against
|
|
113
|
+
// that. Without the check an editor autosaving against the local checkout
|
|
114
|
+
// would silently overwrite a change made underneath it.
|
|
115
|
+
if (typeof body.sha === 'string') {
|
|
116
|
+
let current;
|
|
117
|
+
try {
|
|
118
|
+
current = await readFile(target);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error.code !== 'ENOENT')
|
|
122
|
+
throw error;
|
|
123
|
+
current = null;
|
|
124
|
+
}
|
|
125
|
+
const currentSha = current && createHash('sha256').update(current).digest('hex');
|
|
126
|
+
if (currentSha !== body.sha) {
|
|
127
|
+
throw new HttpError(409, 'The file changed on disk since it was read.');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await mkdir(dirname(target), { recursive: true });
|
|
131
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
132
|
+
try {
|
|
133
|
+
await writeFile(temporary, content);
|
|
134
|
+
await rename(temporary, target);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await rm(temporary, { force: true });
|
|
138
|
+
}
|
|
139
|
+
const sha = createHash('sha256').update(content).digest('hex');
|
|
140
|
+
sendJson(response, 200, { sha, commitSha: sha });
|
|
141
|
+
}
|
|
142
|
+
async function handleDelete(request, response, roots, encodedPath) {
|
|
143
|
+
await readJson(request);
|
|
144
|
+
const target = repositoryPath(roots, encodedPath);
|
|
145
|
+
try {
|
|
146
|
+
await unlink(target);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code === 'ENOENT') {
|
|
150
|
+
throw new HttpError(404, 'File not found.');
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
sendJson(response, 200, {});
|
|
155
|
+
}
|
|
156
|
+
async function handleList(request, response, roots, encodedPath) {
|
|
157
|
+
await readJson(request);
|
|
158
|
+
const target = repositoryPath(roots, encodedPath);
|
|
159
|
+
let entries;
|
|
160
|
+
try {
|
|
161
|
+
entries = await readdir(target, { encoding: 'utf8', withFileTypes: true });
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code === 'ENOENT') {
|
|
165
|
+
throw new HttpError(404, 'Directory not found.');
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
sendJson(response, 200, {
|
|
170
|
+
entries: entries
|
|
171
|
+
.filter((entry) => entry.isFile() || entry.isDirectory())
|
|
172
|
+
.map((entry) => ({
|
|
173
|
+
path: relative(roots.root, resolve(target, entry.name)).split(sep).join('/'),
|
|
174
|
+
type: entry.isDirectory() ? 'dir' : 'file'
|
|
175
|
+
}))
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
export function createLocalVitePlugin(options) {
|
|
179
|
+
const roots = resolveRoots(options);
|
|
180
|
+
const filePrefix = `${LOCAL_API_PATH}/files/`;
|
|
181
|
+
const directoryPrefix = `${LOCAL_API_PATH}/dirs/`;
|
|
182
|
+
return {
|
|
183
|
+
name: 'uncial-cms:local',
|
|
184
|
+
apply(_config, env) {
|
|
185
|
+
return env.command === 'serve';
|
|
186
|
+
},
|
|
187
|
+
config(_config, env) {
|
|
188
|
+
if (env.command !== 'serve')
|
|
189
|
+
return;
|
|
190
|
+
const watched = roots.permitted.map((permitted) => permitted.absolute);
|
|
191
|
+
return {
|
|
192
|
+
server: {
|
|
193
|
+
host: '127.0.0.1',
|
|
194
|
+
// Every write under a permitted root arrives through this plugin's
|
|
195
|
+
// own endpoint, so a watcher event for one is the author's own
|
|
196
|
+
// autosave landing. Watched, it makes Vite full-reload the page
|
|
197
|
+
// mid-edit and the editing session goes with it.
|
|
198
|
+
watch: {
|
|
199
|
+
ignored: [
|
|
200
|
+
(path) => watched.some((root) => path === root || path.startsWith(`${root}${sep}`))
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
configureServer(server) {
|
|
207
|
+
server.middlewares.use((request, response, next) => {
|
|
208
|
+
const path = request.url?.split('?', 1)[0] ?? '';
|
|
209
|
+
const isFile = path.startsWith(filePrefix);
|
|
210
|
+
const isDirectory = path.startsWith(directoryPrefix);
|
|
211
|
+
if (!isFile && !isDirectory)
|
|
212
|
+
return next();
|
|
213
|
+
if (!request.headers['content-type']?.startsWith('application/json')) {
|
|
214
|
+
sendJson(response, 415, { error: 'Requests must use application/json.' });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const handler = isDirectory
|
|
218
|
+
? request.method === 'POST'
|
|
219
|
+
? handleList
|
|
220
|
+
: null
|
|
221
|
+
: request.method === 'POST'
|
|
222
|
+
? handleRead
|
|
223
|
+
: request.method === 'PUT'
|
|
224
|
+
? handleWrite
|
|
225
|
+
: request.method === 'DELETE'
|
|
226
|
+
? handleDelete
|
|
227
|
+
: null;
|
|
228
|
+
if (!handler) {
|
|
229
|
+
sendJson(response, 405, { error: 'Method not allowed.' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const encodedPath = path.slice((isDirectory ? directoryPrefix : filePrefix).length);
|
|
233
|
+
void handler(request, response, roots, encodedPath).catch((error) => {
|
|
234
|
+
if (error instanceof HttpError) {
|
|
235
|
+
sendJson(response, error.status, { error: error.message });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
next(error);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
package/dist/mount.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import 'uncial/web-components';
|
|
2
|
+
import 'uncial/styles/chrome';
|
|
3
|
+
import type { UncialEditorElement } from 'uncial/web-components';
|
|
4
|
+
import type { SessionProvider, UncialCmsSiteConfig } from './types.js';
|
|
5
|
+
export interface MountEditorPageOptions {
|
|
6
|
+
config: UncialCmsSiteConfig;
|
|
7
|
+
sourcePath: string;
|
|
8
|
+
/** Site-relative page path, used in the deterministic commit message
|
|
9
|
+
* `uncial-cms: edit <path>`. Falls back to sourcePath. */
|
|
10
|
+
pagePath?: string;
|
|
11
|
+
blocks: unknown;
|
|
12
|
+
schema: unknown;
|
|
13
|
+
sessionProvider?: SessionProvider;
|
|
14
|
+
/**
|
|
15
|
+
* Debounced autosave in milliseconds. When set, the page saves on its own
|
|
16
|
+
* this long after the last change and renders no Save button — the mode a
|
|
17
|
+
* local-filesystem backend wants, where the file is the document and there
|
|
18
|
+
* is nothing to reconcile. Omitted keeps the Save button and manual saving.
|
|
19
|
+
*/
|
|
20
|
+
autosaveMs?: number;
|
|
21
|
+
/**
|
|
22
|
+
* How the editor renders its block attributes panel — see uncial's
|
|
23
|
+
* `attributesPanel`. A site whose Editor variant is meant to look like the
|
|
24
|
+
* published page wants `'overlay'`, so the panel costs the document no width.
|
|
25
|
+
*/
|
|
26
|
+
attributesPanel?: UncialEditorElement['attributesPanel'];
|
|
27
|
+
/**
|
|
28
|
+
* `'bare'` drops the editor's own surface and inline padding so the document
|
|
29
|
+
* is laid out in exactly the box the host gives it — see uncial's
|
|
30
|
+
* `presentation`. Pairs with `attributesPanel: 'overlay'` for WYSIWYG parity.
|
|
31
|
+
*/
|
|
32
|
+
presentation?: UncialEditorElement['presentation'];
|
|
33
|
+
/**
|
|
34
|
+
* Stylesheet URLs to load inside the editor's shadow root. Defaults to
|
|
35
|
+
* mirroring every stylesheet of the host page (WYSIWYG parity: the editor
|
|
36
|
+
* renders behind a shadow boundary, which page styles do not cross).
|
|
37
|
+
*/
|
|
38
|
+
editorStylesheets?: string[];
|
|
39
|
+
}
|
|
40
|
+
export declare function mountEditorPage(target: HTMLElement, opts: MountEditorPageOptions): {
|
|
41
|
+
destroy(): void;
|
|
42
|
+
isDirty(): boolean;
|
|
43
|
+
};
|