@drawnagency/primitives 0.1.99 → 0.1.101
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/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
- package/dist/{chunk-HKHEAH5F.js → chunk-6SLGKRLW.js} +8 -60
- package/dist/chunk-BS5AQHZG.js +45 -0
- package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
- package/dist/{chunk-KP4DWTPR.js → chunk-P3NNI3QI.js} +328 -4
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
- package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
- package/dist/components/docfile/DocFilePane.d.ts +17 -0
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
- package/dist/components/docfile/doc-file-state.d.ts +38 -0
- package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +1 -1
- package/dist/components/primitives/DocumentDropZone.d.ts +18 -0
- package/dist/components/primitives/DocumentDropZone.d.ts.map +1 -0
- package/dist/components/sections/Document/index.d.ts +24 -0
- package/dist/components/sections/Document/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +10 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.d.ts +10 -0
- package/dist/components/sections/brand-guide.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +3 -1
- package/dist/components/sections/register-schemas.js +5 -3
- package/dist/components/sections/register.js +5 -3
- package/dist/components/shared/DocumentSourceField.d.ts +13 -0
- package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorModal.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTile.d.ts +3 -1
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTree.d.ts +3 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +24 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/hooks/useDocumentUploads.d.ts +32 -0
- package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +18 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +14 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useResolvedDocument.d.ts +12 -0
- package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
- package/dist/index.js +56 -32
- package/dist/lib/dexie.d.ts +13 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +39 -2
- package/dist/lib/document-refs.d.ts +22 -0
- package/dist/lib/document-refs.d.ts.map +1 -0
- package/dist/lib/document-uploads.d.ts +38 -0
- package/dist/lib/document-uploads.d.ts.map +1 -0
- package/dist/lib/documents.d.ts +65 -0
- package/dist/lib/documents.d.ts.map +1 -0
- package/dist/lib/index.js +4 -4
- package/dist/lib/media-folders.d.ts +8 -0
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +2 -0
- package/dist/lib/nav.d.ts +29 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/platform-broker.d.ts +12 -0
- package/dist/lib/platform-broker.d.ts.map +1 -1
- package/dist/lib/platform-broker.js +12 -3
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/save-chunks.d.ts +15 -0
- package/dist/lib/save-chunks.d.ts.map +1 -1
- package/dist/lib/scroll-lock.d.ts +2 -0
- package/dist/lib/scroll-lock.d.ts.map +1 -0
- package/dist/schemas/documents.d.ts +80 -0
- package/dist/schemas/documents.d.ts.map +1 -0
- package/dist/schemas/documents.js +25 -0
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +23 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +3 -1
- package/dist/storage/types.d.ts +59 -3
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/chat/ChatPanel.tsx +5 -4
- package/src/components/docfile/DocFileOverlay.tsx +54 -0
- package/src/components/docfile/DocFilePane.tsx +133 -0
- package/src/components/docfile/doc-file-state.ts +208 -0
- package/src/components/editor/SettingsForm.tsx +10 -0
- package/src/components/primitives/DocumentDropZone.tsx +79 -0
- package/src/components/sections/Document/index.tsx +194 -0
- package/src/components/sections/brand-guide.ts +2 -1
- package/src/components/shared/DocumentSourceField.tsx +51 -0
- package/src/components/shared/Navigation.tsx +53 -10
- package/src/components/shell/EditorModal.tsx +2 -5
- package/src/components/shell/EditorShell.tsx +132 -8
- package/src/components/shell/MediaBreadcrumb.tsx +14 -1
- package/src/components/shell/MediaFolderTile.tsx +4 -2
- package/src/components/shell/MediaFolderTree.tsx +20 -3
- package/src/components/shell/MediaLibraryContext.tsx +24 -1
- package/src/components/shell/MediaLibraryModal.tsx +223 -45
- package/src/hooks/useDocumentUploads.ts +336 -0
- package/src/hooks/useEditorPublish.ts +180 -8
- package/src/hooks/useMediaPipeline.ts +141 -5
- package/src/hooks/useResolvedDocument.ts +25 -0
- package/src/lib/dexie.ts +66 -1
- package/src/lib/document-refs.ts +83 -0
- package/src/lib/document-uploads.ts +66 -0
- package/src/lib/documents.ts +138 -0
- package/src/lib/media-folders.ts +9 -0
- package/src/lib/nav.ts +46 -2
- package/src/lib/platform-broker.ts +18 -3
- package/src/lib/registry.ts +9 -0
- package/src/lib/save-chunks.ts +42 -0
- package/src/lib/scroll-lock.ts +27 -0
- package/src/schemas/documents.ts +82 -0
- package/src/schemas/index.ts +1 -0
- package/src/storage/index.ts +2 -2
- package/src/storage/types.ts +58 -4
- /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { LoadedSection } from "./loader";
|
|
2
|
+
import type { Section } from "../schemas/sections";
|
|
3
|
+
import type { SiteIndex, SectionMeta } from "../schemas/site-config";
|
|
4
|
+
import type { DocumentManifest } from "../schemas/documents";
|
|
5
|
+
import { documentFileUrl } from "../schemas/documents";
|
|
6
|
+
import { getContainerChildren } from "./container-ops";
|
|
7
|
+
import { audiencePasses } from "./pages";
|
|
8
|
+
|
|
9
|
+
// The gated URL has exactly ONE definition, in schemas/documents (S1). This is
|
|
10
|
+
// a re-export so `@/lib/documents` consumers get it without a second spelling.
|
|
11
|
+
export { documentFileUrl };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One `document` section, joined against the document manifest and filtered to
|
|
15
|
+
* what THIS requester may see. Derived per request — never persisted, never
|
|
16
|
+
* written back into index.json or nav.json.
|
|
17
|
+
*/
|
|
18
|
+
export interface SiteDocument {
|
|
19
|
+
sectionId: string;
|
|
20
|
+
/** Manifest key. Carried so the overlay can build the ?download=1 URL with
|
|
21
|
+
* documentFileUrl(), and so the editor can key a staged blob URL by id. */
|
|
22
|
+
documentId: string;
|
|
23
|
+
title: string;
|
|
24
|
+
filename: string;
|
|
25
|
+
size: number;
|
|
26
|
+
kind: "pdf" | "html";
|
|
27
|
+
/** The auth-gated route. Append `?download=1` for an attachment response. */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Mirrors content.showInDocumentsNav — generateSiteNav filters on it, while
|
|
30
|
+
* the overlay and the rows need the unfiltered list (an unflagged document
|
|
31
|
+
* is still openable from its own row). */
|
|
32
|
+
showInDocumentsNav: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DocumentSectionContent {
|
|
36
|
+
title?: string;
|
|
37
|
+
doc?: { documentId?: string };
|
|
38
|
+
showInDocumentsNav?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Filename without its extension. The document row and the overlay's top bar
|
|
43
|
+
* render `content.title || filenameStem(entry.filename)` — a DISPLAY fallback,
|
|
44
|
+
* not a write. (SettingsForm.handleFieldChange is per-key and cannot set a
|
|
45
|
+
* sibling field, so "auto-fill title on pick" would need cross-field machinery
|
|
46
|
+
* for the same visible result.)
|
|
47
|
+
*/
|
|
48
|
+
export function filenameStem(filename: string): string {
|
|
49
|
+
const dot = filename.lastIndexOf(".");
|
|
50
|
+
return dot > 0 ? filename.slice(0, dot) : filename;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Compact byte size for the row's `filename · size` line. */
|
|
54
|
+
export function formatDocSize(bytes: number): string {
|
|
55
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
56
|
+
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
|
57
|
+
const units = ["KB", "MB", "GB"];
|
|
58
|
+
let value = bytes / 1024;
|
|
59
|
+
let unit = 0;
|
|
60
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
61
|
+
value /= 1024;
|
|
62
|
+
unit++;
|
|
63
|
+
}
|
|
64
|
+
return `${value >= 100 ? Math.round(value) : Number(value.toFixed(1))} ${units[unit]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every document the requester may see, in page order.
|
|
69
|
+
*
|
|
70
|
+
* Viewer mode mirrors viewer-page.ts exactly: the owning page must be live and
|
|
71
|
+
* audience-passing, AND the section's own meta must be live and audience-
|
|
72
|
+
* passing. Editors see everything on the draft. A document with no manifest
|
|
73
|
+
* entry is excluded in BOTH modes — there are no bytes to serve and no
|
|
74
|
+
* filename/size to render; the editor's row derives its "file missing" state
|
|
75
|
+
* from that absence.
|
|
76
|
+
*
|
|
77
|
+
* Documents nested one level inside a container count too, and are emitted right
|
|
78
|
+
* after their container's own position. A child has no meta of its own and never
|
|
79
|
+
* appears in `page.order`, so both gates read the PARENT's meta and the PARENT's
|
|
80
|
+
* page; the emitted `sectionId` is the CHILD's — that is what `DocumentRow`
|
|
81
|
+
* matches and what `#doc=` deep links carry. Depth 1 only: `MAX_BLOCK_DEPTH`
|
|
82
|
+
* makes anything deeper unrepresentable on write and on load.
|
|
83
|
+
*
|
|
84
|
+
* This is one half of a documented mirror. `referencedByVisibleSection` (the
|
|
85
|
+
* gated file route) applies the same gates with the same recursion — change one,
|
|
86
|
+
* change the other, or a document becomes listable but unservable (or worse).
|
|
87
|
+
*/
|
|
88
|
+
export function deriveSiteDocuments(opts: {
|
|
89
|
+
index: SiteIndex;
|
|
90
|
+
sections: LoadedSection[];
|
|
91
|
+
manifest: DocumentManifest;
|
|
92
|
+
mode: "viewer" | "editor";
|
|
93
|
+
audience: string | null;
|
|
94
|
+
}): SiteDocument[] {
|
|
95
|
+
const { index, sections, manifest, mode, audience } = opts;
|
|
96
|
+
|
|
97
|
+
const pageBySectionId = new Map<string, SiteIndex["pages"][number]>();
|
|
98
|
+
for (const page of index.pages) for (const id of page.order) pageBySectionId.set(id, page);
|
|
99
|
+
|
|
100
|
+
const out: SiteDocument[] = [];
|
|
101
|
+
const seen = new Set<string>();
|
|
102
|
+
|
|
103
|
+
/** `candidate` supplies the id and the body; `owner` (itself, or the container
|
|
104
|
+
* it sits in) supplies the meta and the page. */
|
|
105
|
+
const consider = (candidate: Section, ownerId: string, meta: SectionMeta) => {
|
|
106
|
+
if (candidate.type !== "document" || !candidate.id) return;
|
|
107
|
+
const content = candidate.content as DocumentSectionContent;
|
|
108
|
+
const documentId = content.doc?.documentId ?? "";
|
|
109
|
+
const entry = documentId ? manifest[documentId] : undefined;
|
|
110
|
+
if (!entry) return;
|
|
111
|
+
|
|
112
|
+
if (mode === "viewer") {
|
|
113
|
+
const page = pageBySectionId.get(ownerId);
|
|
114
|
+
if (!page || page.status !== "live" || !audiencePasses(page.access, audience)) return;
|
|
115
|
+
if (meta.status !== "live" || !audiencePasses(meta.access, audience)) return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (seen.has(candidate.id)) return;
|
|
119
|
+
seen.add(candidate.id);
|
|
120
|
+
|
|
121
|
+
out.push({
|
|
122
|
+
sectionId: candidate.id,
|
|
123
|
+
documentId,
|
|
124
|
+
title: content.title?.trim() || filenameStem(entry.filename),
|
|
125
|
+
filename: entry.filename,
|
|
126
|
+
size: entry.size,
|
|
127
|
+
kind: entry.kind,
|
|
128
|
+
url: documentFileUrl(documentId, entry.filename),
|
|
129
|
+
showInDocumentsNav: content.showInDocumentsNav !== false,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const { section, meta } of sections) {
|
|
134
|
+
consider(section, section.id, meta);
|
|
135
|
+
for (const child of getContainerChildren(section)) consider(child, section.id, meta);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
package/src/lib/media-folders.ts
CHANGED
|
@@ -13,6 +13,15 @@ export interface FolderUploadPlan {
|
|
|
13
13
|
fileTargets: { file: File; folderId: string | null }[];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The library's Documents pseudo-folder. UI-only: it is NEVER a `folders` entry
|
|
18
|
+
* (they round-trip to disk verbatim, and no MediaItem can point at it), so
|
|
19
|
+
* every `currentFolderId` consumer that reads `folders` must special-case it.
|
|
20
|
+
* Cannot collide with a real `f_`-prefixed id; distinct from the nav's
|
|
21
|
+
* `__documents__` group id.
|
|
22
|
+
*/
|
|
23
|
+
export const DOCUMENTS_FOLDER_ID = "__documents-folder__";
|
|
24
|
+
|
|
16
25
|
/** Display-folder id. NOT related to MediaItem.folder (the git storage dir). */
|
|
17
26
|
export function createFolderId(): string {
|
|
18
27
|
const bytes = new Uint8Array(6);
|
package/src/lib/nav.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getSection, type SectionRegistry } from "./registry";
|
|
|
3
3
|
import { audiencePasses, homePage, pageDisplayTitle } from "./pages";
|
|
4
4
|
import { navEntries } from "./nav-tree";
|
|
5
5
|
import type { SiteIndex } from "../schemas/site-config";
|
|
6
|
+
import type { SiteDocument } from "./documents";
|
|
6
7
|
|
|
7
8
|
export interface NavItem {
|
|
8
9
|
href: string;
|
|
@@ -102,7 +103,30 @@ export interface GroupNavItem {
|
|
|
102
103
|
pages: PageNavItem[];
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
/** A launcher entry for one document section. NOT a PageNavItem: the editor's
|
|
107
|
+
* page rendering turns hrefs into pageSelectEvent buttons, which would
|
|
108
|
+
* dead-end here. */
|
|
109
|
+
export interface DocNavItem {
|
|
110
|
+
kind: "doc";
|
|
111
|
+
sectionId: string;
|
|
112
|
+
title: string;
|
|
113
|
+
url: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The synthetic, never-persisted "Documents" group. GroupNavItem.pages is
|
|
117
|
+
* PageNavItem[], so the doc group is its own entry kind rather than a reuse. */
|
|
118
|
+
export interface DocGroupNavItem {
|
|
119
|
+
kind: "doc-group";
|
|
120
|
+
id: string;
|
|
121
|
+
label: string;
|
|
122
|
+
docs: DocNavItem[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Id of the synthetic group. Generated page-group ids never collide with it,
|
|
126
|
+
* and it is never written to nav.json. */
|
|
127
|
+
export const DOCUMENTS_GROUP_ID = "__documents__";
|
|
128
|
+
|
|
129
|
+
export type SiteNavEntry = ({ kind: "page" } & PageNavItem) | GroupNavItem | DocGroupNavItem;
|
|
106
130
|
|
|
107
131
|
export interface SiteNav {
|
|
108
132
|
/** The authored tree: loose pages and groups, in nav order. */
|
|
@@ -123,8 +147,15 @@ export function generateSiteNav(opts: {
|
|
|
123
147
|
mode: "viewer" | "editor";
|
|
124
148
|
audience: string | null;
|
|
125
149
|
registry?: SectionRegistry;
|
|
150
|
+
/** Pre-derived and already requester-filtered (deriveSiteDocuments). This
|
|
151
|
+
* function cannot see SITE-WIDE section content — activeSections carries at
|
|
152
|
+
* most the current page's sections, and EditorShell's two history call
|
|
153
|
+
* sites pass activeSections: [] outright — so the list is always supplied
|
|
154
|
+
* by the caller. Callers without one (history views) pass nothing and
|
|
155
|
+
* render no group. */
|
|
156
|
+
documents?: SiteDocument[];
|
|
126
157
|
}): SiteNav {
|
|
127
|
-
const { index, activeSections, activePageId, mode, audience, registry } = opts;
|
|
158
|
+
const { index, activeSections, activePageId, mode, audience, registry, documents } = opts;
|
|
128
159
|
const active = activePageId ?? homePage(index).id;
|
|
129
160
|
const byId = new Map(index.pages.map((p) => [p.id, p]));
|
|
130
161
|
|
|
@@ -163,6 +194,19 @@ export function generateSiteNav(opts: {
|
|
|
163
194
|
});
|
|
164
195
|
}
|
|
165
196
|
|
|
197
|
+
// Derived, never persisted: the Documents group lives only in this view model,
|
|
198
|
+
// so nav.json is never written, reconcileNav never sees it, and the Pages
|
|
199
|
+
// modal's positional drag indexing is untouched.
|
|
200
|
+
const navDocs = (documents ?? []).filter((d) => d.showInDocumentsNav);
|
|
201
|
+
if (navDocs.length > 0) {
|
|
202
|
+
entries.push({
|
|
203
|
+
kind: "doc-group",
|
|
204
|
+
id: DOCUMENTS_GROUP_ID,
|
|
205
|
+
label: "Documents",
|
|
206
|
+
docs: navDocs.map((d) => ({ kind: "doc" as const, sectionId: d.sectionId, title: d.title, url: d.url })),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
166
210
|
if (mode === "editor") {
|
|
167
211
|
return {
|
|
168
212
|
entries,
|
|
@@ -7,6 +7,21 @@ function sleep(ms: number): Promise<void> {
|
|
|
7
7
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A broker call that reached apps/admin and got a non-2xx back. The `status` is
|
|
12
|
+
* carried so callers can distinguish a MISSING endpoint (404/405/501 — the
|
|
13
|
+
* stale-admin window, where the client site has been upgraded ahead of the
|
|
14
|
+
* platform) from a real auth or config failure. Without it every such case
|
|
15
|
+
* surfaces as an opaque 500 the editor cannot act on. `status` is null when the
|
|
16
|
+
* request never got a response at all.
|
|
17
|
+
*/
|
|
18
|
+
export class PlatformBrokerError extends Error {
|
|
19
|
+
constructor(public readonly status: number | null, message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "PlatformBrokerError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/** True when this runtime should broker privileged ops through the platform. */
|
|
11
26
|
export function isPlatformMode(): boolean {
|
|
12
27
|
return Boolean(env("PLATFORM_API_URL") && env("PLATFORM_API_KEY"));
|
|
@@ -60,15 +75,15 @@ export async function platformBroker<T = unknown>(
|
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
if (res.status >= 500 && !isLast) {
|
|
63
|
-
lastError = new
|
|
78
|
+
lastError = new PlatformBrokerError(res.status, `Platform broker error (${res.status})`);
|
|
64
79
|
await sleep(RETRY_BASE_MS * 2 ** attempt);
|
|
65
80
|
continue;
|
|
66
81
|
}
|
|
67
82
|
|
|
68
|
-
throw new
|
|
83
|
+
throw new PlatformBrokerError(res.status, `Platform broker error (${res.status}): ${await res.text()}`);
|
|
69
84
|
}
|
|
70
85
|
|
|
71
|
-
throw lastError instanceof Error ? lastError : new
|
|
86
|
+
throw lastError instanceof Error ? lastError : new PlatformBrokerError(null, "Platform broker unreachable");
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
/**
|
package/src/lib/registry.ts
CHANGED
|
@@ -62,6 +62,15 @@ export type SettingsFieldDef =
|
|
|
62
62
|
label: string;
|
|
63
63
|
default: VideoSource;
|
|
64
64
|
target?: "content" | "options";
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
/** Opens the media library filtered to documents (PDF/HTML). The renderer
|
|
68
|
+
* lives in SettingsForm; an unrendered kind falls through its `default`
|
|
69
|
+
* arm, so this type can land ahead of the field component. */
|
|
70
|
+
type: "document-source";
|
|
71
|
+
label: string;
|
|
72
|
+
default: { documentId: string };
|
|
73
|
+
target?: "content" | "options";
|
|
65
74
|
};
|
|
66
75
|
|
|
67
76
|
export type SettingsSchema = Record<string, SettingsFieldDef>;
|
package/src/lib/save-chunks.ts
CHANGED
|
@@ -51,3 +51,45 @@ export function packUploads(uploads: MediaUploadPayload[], budget = SAVE_CHUNK_B
|
|
|
51
51
|
if (current.length > 0) chunks.push(current);
|
|
52
52
|
return { chunks, oversized };
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
export interface DocumentUploadPayload {
|
|
56
|
+
id: string;
|
|
57
|
+
path: string;
|
|
58
|
+
base64: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Approximate serialized size of one document upload — base64 dominates; the
|
|
62
|
+
* allowance covers JSON keys, quoting, and the manifest entry riding along. */
|
|
63
|
+
export function documentUploadSizeBytes(upload: DocumentUploadPayload): number {
|
|
64
|
+
return upload.base64.length + upload.path.length + 576;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Greedy in-order packing of document uploads. A 2.5MiB document base64s to
|
|
68
|
+
* ~3.4MB, so two never share a request — hence chunking rather than one blob
|
|
69
|
+
* request. Documents are never packed with sections/index. */
|
|
70
|
+
export function packDocumentUploads(
|
|
71
|
+
uploads: DocumentUploadPayload[],
|
|
72
|
+
budget = SAVE_CHUNK_BUDGET_BYTES,
|
|
73
|
+
): { chunks: DocumentUploadPayload[][]; oversized: DocumentUploadPayload[] } {
|
|
74
|
+
const chunks: DocumentUploadPayload[][] = [];
|
|
75
|
+
const oversized: DocumentUploadPayload[] = [];
|
|
76
|
+
let current: DocumentUploadPayload[] = [];
|
|
77
|
+
let currentBytes = 0;
|
|
78
|
+
|
|
79
|
+
for (const upload of uploads) {
|
|
80
|
+
const size = documentUploadSizeBytes(upload);
|
|
81
|
+
if (size > budget) {
|
|
82
|
+
oversized.push(upload);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (current.length > 0 && currentBytes + size > budget) {
|
|
86
|
+
chunks.push(current);
|
|
87
|
+
current = [];
|
|
88
|
+
currentBytes = 0;
|
|
89
|
+
}
|
|
90
|
+
current.push(upload);
|
|
91
|
+
currentBytes += size;
|
|
92
|
+
}
|
|
93
|
+
if (current.length > 0) chunks.push(current);
|
|
94
|
+
return { chunks, oversized };
|
|
95
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refcounted body-scroll lock.
|
|
3
|
+
*
|
|
4
|
+
* EditorModal and ChatPanel each save/restore `body.style.overflow` themselves,
|
|
5
|
+
* which collides when two lockers overlap — the first to release restores
|
|
6
|
+
* "hidden" as if it were the resting value, or unlocks the page under a still-
|
|
7
|
+
* open surface. The doc overlay and the mobile chat sheet CAN be open at the
|
|
8
|
+
* same time, so this one counts.
|
|
9
|
+
*/
|
|
10
|
+
let depth = 0;
|
|
11
|
+
let previousOverflow = "";
|
|
12
|
+
|
|
13
|
+
export function lockBodyScroll(): () => void {
|
|
14
|
+
if (typeof document === "undefined") return () => {};
|
|
15
|
+
if (depth === 0) {
|
|
16
|
+
previousOverflow = document.body.style.overflow;
|
|
17
|
+
document.body.style.overflow = "hidden";
|
|
18
|
+
}
|
|
19
|
+
depth++;
|
|
20
|
+
let released = false;
|
|
21
|
+
return () => {
|
|
22
|
+
if (released) return;
|
|
23
|
+
released = true;
|
|
24
|
+
depth--;
|
|
25
|
+
if (depth === 0) document.body.style.overflow = previousOverflow;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Document metadata lives in its OWN file, not in image-manifest.json.
|
|
5
|
+
*
|
|
6
|
+
* The image manifest is hard-`.parse`d on every page load across five routes,
|
|
7
|
+
* its remote-merge guard fails open on parse failure, and the authoring CLI
|
|
8
|
+
* parse-catch-WRITES it — so one entry a stale package version cannot
|
|
9
|
+
* represent means whole-site 500s or a wiped manifest. A sidecar is simply
|
|
10
|
+
* absent from an older writer's file list and survives untouched. Same
|
|
11
|
+
* structural answer as nav.json; do not "simplify" this back into either
|
|
12
|
+
* index.json or image-manifest.json.
|
|
13
|
+
*/
|
|
14
|
+
export const DOCUMENT_MANIFEST_PATH = "src/content/document-manifest.json";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Git-tier cap. 2.5 MiB base64-encodes to ~3.4 MB, inside the 4 MB post-base64
|
|
18
|
+
* chunk budget with envelope headroom.
|
|
19
|
+
*/
|
|
20
|
+
export const DOCUMENT_GIT_MAX_BYTES = 2_621_440;
|
|
21
|
+
|
|
22
|
+
/** Bucket-tier cap. Its lower bound is DOCUMENT_GIT_MAX_BYTES — no dead zone. */
|
|
23
|
+
export const DOCUMENT_BUCKET_MAX_BYTES = 209_715_200;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The only two accepted upload types, and the ONLY source of a document's file
|
|
27
|
+
* extension — never the media pipeline's `?? "bin"` fallback.
|
|
28
|
+
*/
|
|
29
|
+
export const DOCUMENT_MIME_TO_EXT = {
|
|
30
|
+
"application/pdf": "pdf",
|
|
31
|
+
"text/html": "html",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/** Reverse of DOCUMENT_MIME_TO_EXT, for setting Content-Type when serving. */
|
|
35
|
+
export const DOCUMENT_KIND_TO_MIME = {
|
|
36
|
+
pdf: "application/pdf",
|
|
37
|
+
html: "text/html",
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
export const DocumentManifestEntrySchema = z.object({
|
|
41
|
+
filename: z.string(),
|
|
42
|
+
size: z.number().int().positive(),
|
|
43
|
+
kind: z.enum(["pdf", "html"]),
|
|
44
|
+
storage: z.enum(["git", "bucket"]),
|
|
45
|
+
/** git: repo path under assets/documents/…; bucket: site-relative object key. */
|
|
46
|
+
path: z.string(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const DocumentManifestSchema = z.record(z.string(), DocumentManifestEntrySchema);
|
|
50
|
+
|
|
51
|
+
export type DocumentManifest = z.infer<typeof DocumentManifestSchema>;
|
|
52
|
+
export type DocumentManifestEntry = z.infer<typeof DocumentManifestEntrySchema>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The gated URL for a document, at BOTH storage tiers. Single-sourced here so
|
|
56
|
+
* the section row, the overlay, the nav group and the tests cannot drift — a
|
|
57
|
+
* bucket-tier document is still fetched through this route, which authorizes
|
|
58
|
+
* the requester and only then 302s to a short-lived presigned GET.
|
|
59
|
+
*/
|
|
60
|
+
export function documentFileUrl(
|
|
61
|
+
documentId: string,
|
|
62
|
+
filename: string,
|
|
63
|
+
opts: { download?: boolean } = {},
|
|
64
|
+
): string {
|
|
65
|
+
const base = `/api/document-file/${documentId}/${encodeURIComponent(filename)}`;
|
|
66
|
+
return opts.download ? `${base}?download=1` : base;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Git-tier repo path. `slugName` comes from sanitizeMediaName(file.name). */
|
|
70
|
+
export function documentGitPath(slugName: string, hash: string, kind: "pdf" | "html"): string {
|
|
71
|
+
return `assets/documents/${slugName}-${hash}/original.${kind}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bucket-tier object key, SITE-RELATIVE — the store prepends the tenant prefix,
|
|
76
|
+
* so this is also the form stored in the manifest, matched by the delete
|
|
77
|
+
* broker's regex, and compared by the publish-time GC. The id is the 32-hex
|
|
78
|
+
* `documentId` minted by upload-intent, never the client's content hash.
|
|
79
|
+
*/
|
|
80
|
+
export function documentBucketPath(documentId: string, kind: "pdf" | "html"): string {
|
|
81
|
+
return `documents/${documentId}/original.${kind}`;
|
|
82
|
+
}
|
package/src/schemas/index.ts
CHANGED
package/src/storage/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { StorageProvider, FileWrite, AssetStore } from "./types";
|
|
2
|
-
export { StorageConflictError, IdempotencyLookupUnavailableError, ASSET_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";
|
|
1
|
+
export type { StorageProvider, FileWrite, AssetStore, DocumentStore } from "./types";
|
|
2
|
+
export { StorageConflictError, IdempotencyLookupUnavailableError, ASSET_CACHE_CONTROL, DOCUMENT_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";
|
package/src/storage/types.ts
CHANGED
|
@@ -135,19 +135,73 @@ export const ASSET_CACHE_CONTROL = "max-age=31536000, immutable";
|
|
|
135
135
|
* can slot in. All paths are SITE-RELATIVE ("media/{hash}/original.mp4") —
|
|
136
136
|
* implementations prepend the tenant prefix ("sites/{siteId}/") themselves so
|
|
137
137
|
* a caller can never address another tenant's objects.
|
|
138
|
+
*
|
|
139
|
+
* This bucket is PUBLIC: an object's URL is statically derivable from the
|
|
140
|
+
* bucket's public base, so `createUploadUrl` returns it once at upload time
|
|
141
|
+
* (built locally in standalone mode, brokered from the admin app's env in
|
|
142
|
+
* platform mode) and the media manifest stores that absolute URL. Rendering
|
|
143
|
+
* reads the manifest — nothing on the read path re-derives a URL.
|
|
138
144
|
*/
|
|
139
145
|
export interface AssetStore {
|
|
140
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* Mint a signed direct-upload URL for the browser (PUT), along with the
|
|
148
|
+
* object's permanent public URL for the caller to persist.
|
|
149
|
+
*/
|
|
141
150
|
createUploadUrl(
|
|
142
151
|
path: string,
|
|
143
152
|
opts: { contentType: string },
|
|
144
153
|
): Promise<{ uploadUrl: string; path: string; publicUrl: string }>;
|
|
145
154
|
|
|
146
|
-
/** Deterministic public CDN URL for an object (no network call). */
|
|
147
|
-
getPublicUrl(path: string): string;
|
|
148
|
-
|
|
149
155
|
delete(paths: string[]): Promise<void>;
|
|
150
156
|
|
|
151
157
|
/** Recursive listing under a site-relative prefix. */
|
|
152
158
|
list(prefix: string): Promise<{ path: string; size: number; updatedAt: string }[]>;
|
|
153
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* `Cache-Control` every DOCUMENT upload must set. Deliberately NOT
|
|
163
|
+
* ASSET_CACHE_CONTROL: documents live in a PRIVATE bucket reachable only
|
|
164
|
+
* through short-lived presigned GETs, and a shared cache keyed on the full
|
|
165
|
+
* signed URL would otherwise retain the bytes for a year after the signature
|
|
166
|
+
* expired. `private` keeps the copy in the requesting browser only.
|
|
167
|
+
*/
|
|
168
|
+
export const DOCUMENT_CACHE_CONTROL = "private, max-age=31536000, immutable";
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Optional PRIVATE document store — a second bucket with no public domain.
|
|
172
|
+
*
|
|
173
|
+
* Deliberately not an AssetStore: the assets bucket is public by configuration
|
|
174
|
+
* at R2_PUBLIC_BASE (videos depend on it), so a signed GET there would protect
|
|
175
|
+
* nothing and hosted HTML would render unsandboxed on a sibling origin. Hence
|
|
176
|
+
* the asymmetry with AssetStore — an asset has a permanent public URL handed
|
|
177
|
+
* back by `createUploadUrl`, while a document has no durable URL at all: every
|
|
178
|
+
* read is a short-lived signed GET from `createDownloadUrl`, minted only after
|
|
179
|
+
* the gated route has authorized the requester.
|
|
180
|
+
*
|
|
181
|
+
* Paths are SITE-RELATIVE ("documents/{hash}/original.pdf"); implementations
|
|
182
|
+
* prepend the tenant prefix themselves so a caller can never address another
|
|
183
|
+
* tenant's objects.
|
|
184
|
+
*/
|
|
185
|
+
export interface DocumentStore {
|
|
186
|
+
/** Mint a signed direct-upload URL for the browser (PUT). */
|
|
187
|
+
createUploadUrl(
|
|
188
|
+
path: string,
|
|
189
|
+
opts: { contentType: string },
|
|
190
|
+
): Promise<{ uploadUrl: string; path: string }>;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mint a short-lived signed GET. `downloadFilename`, when given, signs
|
|
194
|
+
* `response-content-disposition: attachment` into the URL — R2 sends no
|
|
195
|
+
* Content-Disposition of its own, so this is the only way ?download=1 can
|
|
196
|
+
* force a download on the bucket tier.
|
|
197
|
+
*/
|
|
198
|
+
createDownloadUrl(
|
|
199
|
+
path: string,
|
|
200
|
+
opts: { expiresInSeconds: number; downloadFilename?: string },
|
|
201
|
+
): Promise<string>;
|
|
202
|
+
|
|
203
|
+
delete(paths: string[]): Promise<void>;
|
|
204
|
+
|
|
205
|
+
/** Recursive listing under a site-relative prefix. */
|
|
206
|
+
list(prefix: string): Promise<Array<{ path: string; size: number; updatedAt: string }>>;
|
|
207
|
+
}
|
|
File without changes
|