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
@@ -0,0 +1,135 @@
1
+ import { bytesToBase64, decodeBase64, encodeBase64 } from '../base64.js';
2
+ import { MAX_CONTENT_BYTES } from '../constants.js';
3
+ import { ConflictError, NotFoundError } from '../errors.js';
4
+ import { clearCachedSession, readCachedSession, writeCachedSession } from '../session.js';
5
+ export const GITHUB_API_URL = 'https://api.github.com';
6
+ function encodeRepoPath(path) {
7
+ return path.split('/').map(encodeURIComponent).join('/');
8
+ }
9
+ class GitHubAdapter {
10
+ #config = null;
11
+ #provider = null;
12
+ #session = null;
13
+ async authenticate(config, provider) {
14
+ if (config.forge !== 'github') {
15
+ throw new Error('GitHub adapter requires a GitHub site configuration.');
16
+ }
17
+ this.#config = config;
18
+ this.#provider = provider;
19
+ this.#session = readCachedSession(config.repo) ?? (await this.#renewSession());
20
+ return this.#session;
21
+ }
22
+ async readFile(path) {
23
+ const response = await this.#request(`${this.#contentsUrl(path)}?ref=${this.#config.branch}`);
24
+ if (response.status === 404) {
25
+ throw new NotFoundError(`File not found in ${this.#config.repo}@${this.#config.branch}: ${path}`);
26
+ }
27
+ await this.#assertOk(response, `read ${path}`);
28
+ const file = (await response.json());
29
+ if (Array.isArray(file)) {
30
+ throw new Error(`Expected a file but found a directory: ${path}`);
31
+ }
32
+ if (file.encoding !== 'base64' || (file.size ?? 0) > MAX_CONTENT_BYTES) {
33
+ throw new Error(`Document ${path} exceeds the 1 MB limit of the GitHub Contents API and cannot be edited.`);
34
+ }
35
+ return { content: decodeBase64(file.content ?? ''), sha: file.sha };
36
+ }
37
+ async writeFile(path, content, opts) {
38
+ const response = await this.#request(this.#contentsUrl(path), {
39
+ method: 'PUT',
40
+ body: JSON.stringify({
41
+ message: opts.message,
42
+ content: typeof content === 'string' ? encodeBase64(content) : bytesToBase64(content),
43
+ branch: this.#config.branch,
44
+ ...(opts.sha === undefined ? {} : { sha: opts.sha }),
45
+ author: opts.author
46
+ })
47
+ });
48
+ if (response.status === 409)
49
+ throw new ConflictError();
50
+ await this.#assertOk(response, `write ${path}`);
51
+ const result = (await response.json());
52
+ return { sha: result.content.sha, commitSha: result.commit.sha };
53
+ }
54
+ async deleteFile(path, opts) {
55
+ const response = await this.#request(this.#contentsUrl(path), {
56
+ method: 'DELETE',
57
+ body: JSON.stringify({
58
+ message: opts.message,
59
+ sha: opts.sha,
60
+ branch: this.#config.branch
61
+ })
62
+ });
63
+ if (response.status === 409)
64
+ throw new ConflictError();
65
+ await this.#assertOk(response, `delete ${path}`);
66
+ }
67
+ async listDir(path) {
68
+ const response = await this.#request(`${this.#contentsUrl(path)}?ref=${this.#config.branch}`);
69
+ await this.#assertOk(response, `list ${path}`);
70
+ const entries = (await response.json());
71
+ if (!Array.isArray(entries)) {
72
+ throw new Error(`Expected a directory but found a file: ${path}`);
73
+ }
74
+ return entries
75
+ .filter((entry) => entry.type === 'file' || entry.type === 'dir')
76
+ .map((entry) => ({ path: entry.path, type: entry.type }));
77
+ }
78
+ async commitStatus(commitSha) {
79
+ const response = await this.#request(`${GITHUB_API_URL}/repos/${this.#config.repo}/commits/${encodeURIComponent(commitSha)}/status`);
80
+ if (!response.ok)
81
+ return 'unknown';
82
+ const { state } = (await response.json());
83
+ if (state === 'pending' || state === 'success')
84
+ return state;
85
+ if (state === 'failure' || state === 'error')
86
+ return 'failure';
87
+ return 'unknown';
88
+ }
89
+ #contentsUrl(path) {
90
+ return `${GITHUB_API_URL}/repos/${this.#config.repo}/contents/${encodeRepoPath(path)}`;
91
+ }
92
+ async #renewSession() {
93
+ const session = await this.#provider(this.#config);
94
+ writeCachedSession(session);
95
+ return session;
96
+ }
97
+ async #request(url, init = {}) {
98
+ const response = await this.#send(url, init);
99
+ if (response.status !== 401)
100
+ return response;
101
+ // Expired/revoked token: clear the session, re-invoke the provider, retry once.
102
+ clearCachedSession(this.#config.repo);
103
+ this.#session = await this.#renewSession();
104
+ return this.#send(url, init);
105
+ }
106
+ async #send(url, init) {
107
+ if (!this.#config || !this.#session) {
108
+ throw new Error('GitHub adapter is not authenticated; call authenticate() first.');
109
+ }
110
+ return fetch(url, {
111
+ ...init,
112
+ headers: {
113
+ Accept: 'application/vnd.github+json',
114
+ Authorization: `Bearer ${this.#session.token}`,
115
+ 'X-GitHub-Api-Version': '2022-11-28',
116
+ ...init.headers
117
+ }
118
+ });
119
+ }
120
+ async #assertOk(response, action) {
121
+ if (response.ok)
122
+ return;
123
+ let detail = '';
124
+ try {
125
+ detail = (await response.json()).message ?? '';
126
+ }
127
+ catch {
128
+ // Non-JSON error body; the status code is enough.
129
+ }
130
+ throw new Error(`GitHub request failed (${response.status}) while trying to ${action}${detail ? `: ${detail}` : ''}`);
131
+ }
132
+ }
133
+ export function createGitHubAdapter() {
134
+ return new GitHubAdapter();
135
+ }
@@ -0,0 +1,3 @@
1
+ export { createGitHubAdapter } from './adapter.js';
2
+ export { patSessionProvider } from './pat.js';
3
+ export { popupSessionProvider } from './popup.js';
@@ -0,0 +1,3 @@
1
+ export { createGitHubAdapter } from './adapter.js';
2
+ export { patSessionProvider } from './pat.js';
3
+ export { popupSessionProvider } from './popup.js';
@@ -0,0 +1,7 @@
1
+ import type { SessionProvider } from '../types.js';
2
+ /**
3
+ * Zero-backend session provider: prompts for a fine-grained personal access
4
+ * token and validates it via `GET /user`. The permanent dev/self-service auth
5
+ * mode; the worker-based provider (issue 03) is layered on the same seam.
6
+ */
7
+ export declare const patSessionProvider: SessionProvider;
@@ -0,0 +1,35 @@
1
+ import { GITHUB_API_URL } from './adapter.js';
2
+ /**
3
+ * Zero-backend session provider: prompts for a fine-grained personal access
4
+ * token and validates it via `GET /user`. The permanent dev/self-service auth
5
+ * mode; the worker-based provider (issue 03) is layered on the same seam.
6
+ */
7
+ export const patSessionProvider = async (config) => {
8
+ const githubConfig = config;
9
+ const token = window
10
+ .prompt(`Paste a GitHub personal access token with contents read/write access to ${githubConfig.repo}:`)
11
+ ?.trim();
12
+ if (!token)
13
+ throw new Error('A personal access token is required to edit this page.');
14
+ const response = await fetch(`${GITHUB_API_URL}/user`, {
15
+ headers: {
16
+ Accept: 'application/vnd.github+json',
17
+ Authorization: `Bearer ${token}`,
18
+ 'X-GitHub-Api-Version': '2022-11-28'
19
+ }
20
+ });
21
+ if (!response.ok) {
22
+ throw new Error(`GitHub rejected the personal access token (${response.status}).`);
23
+ }
24
+ const user = (await response.json());
25
+ return {
26
+ token,
27
+ expiresAt: null,
28
+ repo: githubConfig.repo,
29
+ user: {
30
+ login: user.login,
31
+ name: user.name ?? user.login,
32
+ email: `${user.id}+${user.login}@users.noreply.github.com`
33
+ }
34
+ };
35
+ };
@@ -0,0 +1,9 @@
1
+ import type { SessionProvider } from '../types.js';
2
+ /**
3
+ * Default session provider (issue 03): opens the auth worker in a popup with a
4
+ * PKCE challenge, waits for the worker's callback page to relay {code, state},
5
+ * then finishes the exchange at POST /token. The verifier never leaves this
6
+ * page, and the token that comes back is an installation token scoped to the
7
+ * one configured repository.
8
+ */
9
+ export declare const popupSessionProvider: SessionProvider;
@@ -0,0 +1,75 @@
1
+ function base64UrlEncode(bytes) {
2
+ const view = bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes;
3
+ let binary = '';
4
+ for (const byte of view)
5
+ binary += String.fromCharCode(byte);
6
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
7
+ }
8
+ function waitForRelay(workerOrigin, popup) {
9
+ return new Promise((resolve, reject) => {
10
+ const cleanup = () => {
11
+ window.removeEventListener('message', onMessage);
12
+ clearInterval(closedPoll);
13
+ };
14
+ const onMessage = (event) => {
15
+ if (event.origin !== workerOrigin)
16
+ return;
17
+ const data = event.data;
18
+ if (data?.source !== 'uncial-cms-auth' ||
19
+ typeof data.code !== 'string' ||
20
+ typeof data.state !== 'string') {
21
+ return;
22
+ }
23
+ cleanup();
24
+ resolve({ code: data.code, state: data.state });
25
+ };
26
+ const closedPoll = setInterval(() => {
27
+ if (popup.closed) {
28
+ cleanup();
29
+ reject(new Error('The sign-in popup was closed before completing.'));
30
+ }
31
+ }, 500);
32
+ window.addEventListener('message', onMessage);
33
+ });
34
+ }
35
+ /**
36
+ * Default session provider (issue 03): opens the auth worker in a popup with a
37
+ * PKCE challenge, waits for the worker's callback page to relay {code, state},
38
+ * then finishes the exchange at POST /token. The verifier never leaves this
39
+ * page, and the token that comes back is an installation token scoped to the
40
+ * one configured repository.
41
+ */
42
+ export const popupSessionProvider = async (config) => {
43
+ const githubConfig = config;
44
+ if (!githubConfig.authWorkerUrl) {
45
+ throw new Error('config.authWorkerUrl is not set; configure the auth worker or use patSessionProvider.');
46
+ }
47
+ const workerBase = githubConfig.authWorkerUrl.replace(/\/+$/, '');
48
+ const workerOrigin = new URL(workerBase).origin;
49
+ const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
50
+ const challenge = base64UrlEncode(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
51
+ const authUrl = new URL(`${workerBase}/auth`);
52
+ authUrl.searchParams.set('repo', githubConfig.repo);
53
+ authUrl.searchParams.set('origin', window.location.origin);
54
+ authUrl.searchParams.set('challenge', challenge);
55
+ const popup = window.open(authUrl.toString(), 'uncial-cms-auth', 'popup,width=640,height=760');
56
+ if (!popup)
57
+ throw new Error('The sign-in popup was blocked; allow popups for this site.');
58
+ const { code, state } = await waitForRelay(workerOrigin, popup);
59
+ const response = await fetch(`${workerBase}/token`, {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({ code, state, verifier })
63
+ });
64
+ if (!response.ok) {
65
+ const { error } = (await response.json().catch(() => ({})));
66
+ throw new Error(`Sign-in failed (${error ?? response.status}).`);
67
+ }
68
+ const session = (await response.json());
69
+ return {
70
+ token: session.token,
71
+ expiresAt: session.expiresAt,
72
+ repo: session.repo,
73
+ user: session.user
74
+ };
75
+ };
@@ -0,0 +1,74 @@
1
+ import type { Site } from './define-site.js';
2
+ import { type FitOptions, type ImageEncoder } from './fit-image.js';
3
+ import type { ForgeAdapter } from './types.js';
4
+ export interface CreatePageDeps {
5
+ adapter: ForgeAdapter;
6
+ blocks: unknown;
7
+ schema: unknown;
8
+ author: {
9
+ name: string;
10
+ email: string;
11
+ };
12
+ }
13
+ export interface PageRef {
14
+ pagePath: string;
15
+ sourcePath: string;
16
+ }
17
+ /** Seed `sourcePath` with a normalized empty document (create-mode commit). */
18
+ export declare function createPage(deps: CreatePageDeps, { pagePath, sourcePath }: PageRef): Promise<{
19
+ sha: string;
20
+ commitSha: string;
21
+ }>;
22
+ /** Delete `sourcePath` at its current sha. */
23
+ export declare function deletePage(adapter: ForgeAdapter, { pagePath, sourcePath }: PageRef): Promise<void>;
24
+ export interface UploadAssetFile {
25
+ bytes: Uint8Array;
26
+ filename: string;
27
+ contentType: string;
28
+ }
29
+ export interface UploadAssetOptions {
30
+ /** Repo-root-relative dir the asset is committed into; defaults from `site`. */
31
+ mediaDir?: string;
32
+ /** Supplies `mediaDir` when it is not passed explicitly. */
33
+ site?: Site;
34
+ author: {
35
+ name: string;
36
+ email: string;
37
+ };
38
+ }
39
+ export interface UploadAssetResult {
40
+ path: string;
41
+ sha: string;
42
+ commitSha: string;
43
+ }
44
+ /**
45
+ * Commit an image into the repo at a content-addressed path under `mediaDir`.
46
+ * Identical bytes hash to the same path, so re-uploads reuse the existing file
47
+ * (no second commit). Files over the Contents API limit reject before any
48
+ * network call — there is no git-blobs-API fallback (spec media non-goal).
49
+ */
50
+ export declare function uploadAsset(deps: {
51
+ adapter: ForgeAdapter;
52
+ }, file: UploadAssetFile, opts: UploadAssetOptions): Promise<UploadAssetResult>;
53
+ export interface UploadImageAssetOptions {
54
+ mediaDir?: string;
55
+ site?: Site;
56
+ /**
57
+ * Re-encode an oversize image to fit the forge's limit before committing.
58
+ * `true` takes the defaults; the object form also accepts an `encoder`, the
59
+ * seam {@link fitImage} decodes and encodes through.
60
+ */
61
+ fit?: boolean | (FitOptions & {
62
+ encoder?: ImageEncoder;
63
+ });
64
+ }
65
+ /**
66
+ * Editor-facing convenience over {@link uploadAsset}: resolves the adapter,
67
+ * author and `mediaDir` from the {@link getActiveForge active editor session}
68
+ * so a block's Upload affordance only has to supply the file. Throws a clear
69
+ * error when no editor is mounted (e.g. called before sign-in). Import this
70
+ * dynamically from a block so the reader bundle stays free of CMS runtime.
71
+ */
72
+ export declare function uploadImageAsset(file: UploadAssetFile | File, opts?: UploadImageAssetOptions): Promise<UploadAssetResult>;
73
+ /** Recursively list the content dir's JSON sources, sorted by page path. */
74
+ export declare function listPages(adapter: ForgeAdapter, contentDir: string, mapSourceToPath?: (source: string) => string): Promise<PageRef[]>;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Forge-level actions behind the `/uncial/` index UI (PRD §6.6): list the
3
+ * content dir, seed a new page, delete a page. DOM-free so they unit-test
4
+ * against a fake adapter; `mountIndexPage` wires them to the UI.
5
+ */
6
+ import { normalizeDocument } from 'uncial/core';
7
+ import { MAX_CONTENT_BYTES } from './constants.js';
8
+ import { serializeDocument } from './document.js';
9
+ import { NotFoundError } from './errors.js';
10
+ import { fitImage } from './fit-image.js';
11
+ import { defaultMapSourceToPath } from './paths/index.js';
12
+ import { getActiveForge } from './upload-context.js';
13
+ /** Seed `sourcePath` with a normalized empty document (create-mode commit). */
14
+ export async function createPage(deps, { pagePath, sourcePath }) {
15
+ const blocks = deps.blocks;
16
+ const schema = deps.schema;
17
+ let exists = true;
18
+ try {
19
+ await deps.adapter.readFile(sourcePath);
20
+ }
21
+ catch (error) {
22
+ if (!(error instanceof NotFoundError))
23
+ throw error;
24
+ exists = false;
25
+ }
26
+ if (exists) {
27
+ throw new Error(`A page already exists at "${pagePath}" (${sourcePath}).`);
28
+ }
29
+ const seed = normalizeDocument({ type: 'doc', content: [{ type: 'paragraph' }] }, blocks, schema);
30
+ return deps.adapter.writeFile(sourcePath, serializeDocument(seed, blocks, schema), {
31
+ message: `uncial-cms: create ${pagePath}`,
32
+ author: deps.author
33
+ });
34
+ }
35
+ /** Delete `sourcePath` at its current sha. */
36
+ export async function deletePage(adapter, { pagePath, sourcePath }) {
37
+ const { sha } = await adapter.readFile(sourcePath);
38
+ await adapter.deleteFile(sourcePath, { message: `uncial-cms: delete ${pagePath}`, sha });
39
+ }
40
+ const EXTENSION_FROM_CONTENT_TYPE = {
41
+ 'image/jpeg': 'jpg',
42
+ 'image/svg+xml': 'svg'
43
+ };
44
+ /** Extension from the filename if present, else derived from the content type. */
45
+ function assetExtension(filename, contentType) {
46
+ const dot = filename.lastIndexOf('.');
47
+ if (dot > 0 && dot < filename.length - 1) {
48
+ return filename.slice(dot + 1).toLowerCase();
49
+ }
50
+ const type = contentType.toLowerCase();
51
+ return EXTENSION_FROM_CONTENT_TYPE[type] ?? type.split('/')[1] ?? 'bin';
52
+ }
53
+ /** SHA-256 of the bytes, hex, truncated — the content-addressed asset name. */
54
+ async function contentHash(bytes) {
55
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
56
+ const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0'));
57
+ return hex.join('').slice(0, 32);
58
+ }
59
+ /**
60
+ * Commit an image into the repo at a content-addressed path under `mediaDir`.
61
+ * Identical bytes hash to the same path, so re-uploads reuse the existing file
62
+ * (no second commit). Files over the Contents API limit reject before any
63
+ * network call — there is no git-blobs-API fallback (spec media non-goal).
64
+ */
65
+ export async function uploadAsset(deps, file, opts) {
66
+ if (file.bytes.byteLength > MAX_CONTENT_BYTES) {
67
+ throw new Error(`Image "${file.filename}" is ${file.bytes.byteLength} bytes, over the 1 MB limit of the GitHub Contents API.`);
68
+ }
69
+ const ext = assetExtension(file.filename, file.contentType);
70
+ const hash = await contentHash(file.bytes);
71
+ const dir = resolveMediaDir(opts.mediaDir, opts.site?.config.mediaDir).replace(/\/+$/, '');
72
+ const path = `${dir}/${hash}.${ext}`;
73
+ // Content-addressed: if the path already exists, the bytes are identical.
74
+ try {
75
+ const existing = await deps.adapter.readFile(path);
76
+ return { path, sha: existing.sha, commitSha: '' };
77
+ }
78
+ catch (error) {
79
+ if (!(error instanceof NotFoundError))
80
+ throw error;
81
+ }
82
+ const { sha, commitSha } = await deps.adapter.writeFile(path, file.bytes, {
83
+ message: `uncial-cms: upload ${path}`,
84
+ author: opts.author
85
+ });
86
+ return { path, sha, commitSha };
87
+ }
88
+ /** `mediaDir` from the most specific source that supplies one. */
89
+ function resolveMediaDir(...candidates) {
90
+ const dir = candidates.find((candidate) => candidate);
91
+ if (!dir) {
92
+ throw new Error('No media directory for the upload: pass `mediaDir`, or give a `site` whose config sets it.');
93
+ }
94
+ return dir;
95
+ }
96
+ /** Read a picked `File` (or a `Blob`) into the bytes `uploadAsset` commits. */
97
+ async function assetFileFromBlob(file) {
98
+ return {
99
+ bytes: new Uint8Array(await file.arrayBuffer()),
100
+ filename: file.name || 'image',
101
+ contentType: file.type || 'application/octet-stream'
102
+ };
103
+ }
104
+ /** A `Blob` view of an already-read asset, for handing to {@link fitImage}. */
105
+ function blobFromAssetFile(file) {
106
+ const blob = new Blob([file.bytes], {
107
+ type: file.contentType
108
+ });
109
+ blob.name = file.filename;
110
+ return blob;
111
+ }
112
+ /**
113
+ * Editor-facing convenience over {@link uploadAsset}: resolves the adapter,
114
+ * author and `mediaDir` from the {@link getActiveForge active editor session}
115
+ * so a block's Upload affordance only has to supply the file. Throws a clear
116
+ * error when no editor is mounted (e.g. called before sign-in). Import this
117
+ * dynamically from a block so the reader bundle stays free of CMS runtime.
118
+ */
119
+ export async function uploadImageAsset(file, opts = {}) {
120
+ const forge = getActiveForge();
121
+ if (!forge) {
122
+ throw new Error('No active editor session — open a page in the editor and sign in before uploading.');
123
+ }
124
+ const mediaDir = resolveMediaDir(opts.mediaDir, opts.site?.config.mediaDir, forge.config.mediaDir);
125
+ const asset = opts.fit
126
+ ? await fitImage(file instanceof Blob ? file : blobFromAssetFile(file), opts.fit === true ? {} : opts.fit)
127
+ : file instanceof Blob
128
+ ? await assetFileFromBlob(file)
129
+ : file;
130
+ return uploadAsset({ adapter: forge.adapter }, asset, { mediaDir, author: forge.author });
131
+ }
132
+ /** Recursively list the content dir's JSON sources, sorted by page path. */
133
+ export async function listPages(adapter, contentDir, mapSourceToPath = (source) => defaultMapSourceToPath(source, contentDir)) {
134
+ const sources = [];
135
+ const walk = async (dir) => {
136
+ for (const entry of await adapter.listDir(dir)) {
137
+ if (entry.type === 'dir')
138
+ await walk(entry.path);
139
+ else if (entry.path.endsWith('.json'))
140
+ sources.push(entry.path);
141
+ }
142
+ };
143
+ await walk(contentDir);
144
+ return sources
145
+ .map((sourcePath) => ({ pagePath: mapSourceToPath(sourcePath), sourcePath }))
146
+ .sort((a, b) => a.pagePath.localeCompare(b.pagePath));
147
+ }
@@ -0,0 +1,19 @@
1
+ import type { SessionProvider, UncialCmsSiteConfig } from './types.js';
2
+ export interface MountIndexPageOptions {
3
+ config: UncialCmsSiteConfig;
4
+ blocks: unknown;
5
+ schema: unknown;
6
+ sessionProvider?: SessionProvider;
7
+ /** Site-relative page path → repo-root-relative JSON path. Defaults to the
8
+ * mapping convention; must match the one baked into the editor variants. */
9
+ mapPathToSource?: (path: string) => string;
10
+ /** Inverse of mapPathToSource, used to label listed sources. */
11
+ mapSourceToPath?: (source: string) => string;
12
+ /** URL prefix for live-page links (the framework's base path), e.g.
13
+ * '/uncial/cms-demo'. Default ''. */
14
+ basePath?: string;
15
+ editorStylesheets?: string[];
16
+ }
17
+ export declare function mountIndexPage(target: HTMLElement, opts: MountIndexPageOptions): {
18
+ destroy(): void;
19
+ };