@drawnagency/primitives 0.1.98 → 0.1.100

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 (138) hide show
  1. package/dist/{chunk-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
  2. package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
  5. package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
  6. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  7. package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
  8. package/dist/collab/rebase.d.ts.map +1 -1
  9. package/dist/collab/rebase.js +3 -1
  10. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  11. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  12. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  13. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  14. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  15. package/dist/components/docfile/doc-file-state.d.ts +33 -0
  16. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/editor/index.js +1 -1
  19. package/dist/components/sections/Document/index.d.ts +18 -0
  20. package/dist/components/sections/Document/index.d.ts.map +1 -0
  21. package/dist/components/sections/all-sections.d.ts +10 -0
  22. package/dist/components/sections/all-sections.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.d.ts +10 -0
  24. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  25. package/dist/components/sections/brand-guide.js +3 -1
  26. package/dist/components/sections/register-schemas.js +5 -3
  27. package/dist/components/sections/register.js +5 -3
  28. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  29. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  30. package/dist/components/shared/Navigation.d.ts.map +1 -1
  31. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  32. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  33. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  35. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  36. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  37. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
  39. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  40. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  41. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  42. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  43. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  44. package/dist/hooks/useEditorPublish.d.ts +23 -1
  45. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  46. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  47. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  48. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  49. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  50. package/dist/index.js +60 -34
  51. package/dist/lib/block-move.d.ts.map +1 -1
  52. package/dist/lib/dexie.d.ts +11 -0
  53. package/dist/lib/dexie.d.ts.map +1 -1
  54. package/dist/lib/dexie.js +32 -0
  55. package/dist/lib/document-refs.d.ts +22 -0
  56. package/dist/lib/document-refs.d.ts.map +1 -0
  57. package/dist/lib/document-uploads.d.ts +38 -0
  58. package/dist/lib/document-uploads.d.ts.map +1 -0
  59. package/dist/lib/documents.d.ts +65 -0
  60. package/dist/lib/documents.d.ts.map +1 -0
  61. package/dist/lib/index-ops.d.ts +1 -0
  62. package/dist/lib/index-ops.d.ts.map +1 -1
  63. package/dist/lib/index-ops.js +13 -1
  64. package/dist/lib/index.js +6 -6
  65. package/dist/lib/loader.d.ts +6 -0
  66. package/dist/lib/loader.d.ts.map +1 -1
  67. package/dist/lib/media-folders.d.ts +8 -0
  68. package/dist/lib/media-folders.d.ts.map +1 -1
  69. package/dist/lib/media-folders.js +2 -0
  70. package/dist/lib/nav.d.ts +29 -1
  71. package/dist/lib/nav.d.ts.map +1 -1
  72. package/dist/lib/pages.d.ts +18 -0
  73. package/dist/lib/pages.d.ts.map +1 -1
  74. package/dist/lib/platform-broker.d.ts +12 -0
  75. package/dist/lib/platform-broker.d.ts.map +1 -1
  76. package/dist/lib/platform-broker.js +12 -3
  77. package/dist/lib/registry.d.ts +10 -0
  78. package/dist/lib/registry.d.ts.map +1 -1
  79. package/dist/lib/save-chunks.d.ts +15 -0
  80. package/dist/lib/save-chunks.d.ts.map +1 -1
  81. package/dist/lib/scroll-lock.d.ts +2 -0
  82. package/dist/lib/scroll-lock.d.ts.map +1 -0
  83. package/dist/schemas/documents.d.ts +80 -0
  84. package/dist/schemas/documents.d.ts.map +1 -0
  85. package/dist/schemas/documents.js +25 -0
  86. package/dist/schemas/index.d.ts +1 -0
  87. package/dist/schemas/index.d.ts.map +1 -1
  88. package/dist/schemas/index.js +24 -2
  89. package/dist/storage/index.d.ts +2 -2
  90. package/dist/storage/index.d.ts.map +1 -1
  91. package/dist/storage/index.js +5 -1
  92. package/dist/storage/types.d.ts +69 -5
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/dist/types/database.d.ts +14 -0
  95. package/dist/types/database.d.ts.map +1 -1
  96. package/package.json +5 -1
  97. package/src/collab/rebase.ts +15 -0
  98. package/src/components/chat/ChatPanel.tsx +5 -4
  99. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  100. package/src/components/docfile/DocFilePane.tsx +129 -0
  101. package/src/components/docfile/doc-file-state.ts +180 -0
  102. package/src/components/editor/SettingsForm.tsx +10 -0
  103. package/src/components/sections/Document/index.tsx +163 -0
  104. package/src/components/sections/brand-guide.ts +2 -1
  105. package/src/components/shared/DocumentSourceField.tsx +51 -0
  106. package/src/components/shared/Navigation.tsx +53 -10
  107. package/src/components/shell/EditorModal.tsx +2 -5
  108. package/src/components/shell/EditorShell.tsx +154 -10
  109. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  110. package/src/components/shell/MediaFolderTile.tsx +4 -2
  111. package/src/components/shell/MediaFolderTree.tsx +20 -3
  112. package/src/components/shell/MediaLibraryContext.tsx +18 -1
  113. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  114. package/src/hooks/useDocumentUploads.ts +330 -0
  115. package/src/hooks/useEditorPublish.ts +197 -11
  116. package/src/hooks/useMediaPipeline.ts +136 -5
  117. package/src/hooks/useResolvedDocument.ts +25 -0
  118. package/src/lib/block-move.ts +15 -4
  119. package/src/lib/dexie.ts +54 -0
  120. package/src/lib/document-refs.ts +83 -0
  121. package/src/lib/document-uploads.ts +66 -0
  122. package/src/lib/documents.ts +138 -0
  123. package/src/lib/index-ops.ts +16 -0
  124. package/src/lib/loader.ts +32 -11
  125. package/src/lib/media-folders.ts +9 -0
  126. package/src/lib/nav.ts +46 -2
  127. package/src/lib/pages.ts +48 -0
  128. package/src/lib/platform-broker.ts +18 -3
  129. package/src/lib/registry.ts +9 -0
  130. package/src/lib/save-chunks.ts +42 -0
  131. package/src/lib/scroll-lock.ts +27 -0
  132. package/src/schemas/documents.ts +82 -0
  133. package/src/schemas/index.ts +1 -0
  134. package/src/schemas/nav.ts +3 -3
  135. package/src/storage/index.ts +2 -2
  136. package/src/storage/types.ts +71 -5
  137. package/src/types/database.ts +14 -0
  138. /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
@@ -3,7 +3,16 @@ import type { Section } from "../schemas/sections";
3
3
  import type { SiteIndex, SectionMeta } from "../schemas/site-config";
4
4
  import { ROOT_CONTAINER_ID, canDropBlock } from "./block-dnd";
5
5
  import { getContainerChildren, reorderChild, insertChildAt, removeChildAt, placeBlockAtColumn, trimTrailingSpacers } from "./container-ops";
6
- import { reorderSectionInPage, addSectionToPage, removeSectionFromPages } from "./pages";
6
+ import { reorderVisibleSectionInPage, visibleInsertPosition, addSectionToPage, removeSectionFromPages } from "./pages";
7
+
8
+ /** The root list the canvas actually renders: page order filtered to loaded
9
+ * bodies. Drag indices refer to THIS list, not to `page.order` — a section
10
+ * whose body the loader dropped (unparseable file, schema miss) is invisible
11
+ * but still holds its order slot. */
12
+ function visibleRootIds(index: SiteIndex, rootPageId: string, sections: LoadedSection[]): string[] {
13
+ const bodyIds = new Set(sections.map((l) => l.section.id));
14
+ return index.pages.find((p) => p.id === rootPageId)?.order.filter((id) => bodyIds.has(id)) ?? [];
15
+ }
7
16
 
8
17
  export interface BlockMoveState {
9
18
  sections: LoadedSection[];
@@ -84,11 +93,11 @@ export function applyBlockMove(
84
93
 
85
94
  if (fromContainerId === toContainerId) {
86
95
  // toIndex is the raw insertion index in the original (pre-removal) array.
87
- // Both reorderSectionInPage and reorderChild use splice(from,1)+splice(to,0,moved),
96
+ // Both reorder paths use splice(from,1)+splice(to,0,moved),
88
97
  // so we must convert to the post-removal position for same-container moves.
89
98
  const adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex;
90
99
  if (fromContainerId === ROOT_CONTAINER_ID) {
91
- const nextIndex = reorderSectionInPage(index, rootPageId, fromIndex, adjusted);
100
+ const nextIndex = reorderVisibleSectionInPage(index, rootPageId, visibleRootIds(index, rootPageId, sections), fromIndex, adjusted);
92
101
  if (nextIndex === index) return noop(state);
93
102
  return {
94
103
  sections,
@@ -133,7 +142,9 @@ export function applyBlockMove(
133
142
  if (toContainerId === ROOT_CONTAINER_ID) {
134
143
  const { layout: _dropLayout, ...rootBlock } = moving as Section & { layout?: unknown };
135
144
  const meta = DEFAULT_CHILD_META(rootBlock.type);
136
- nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta, toIndex);
145
+ const rootOrder = nextIndex.pages.find((p) => p.id === rootPageId)?.order ?? [];
146
+ nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta,
147
+ visibleInsertPosition(rootOrder, visibleRootIds(nextIndex, rootPageId, nextSections), toIndex));
137
148
  nextSections = [...nextSections, { section: rootBlock as Section, meta }];
138
149
  indexDirty = true;
139
150
  createSectionIds.push(rootBlock.id);
package/src/lib/dexie.ts CHANGED
@@ -4,6 +4,7 @@ import type { SiteIndex, SectionMeta, SiteConfig, Page } from "../schemas/site-c
4
4
  import type { NavEntry } from "../schemas/nav";
5
5
  import type { LoadedSection } from "./loader";
6
6
  import type { MediaManifest, MediaItem } from "../media/types";
7
+ import type { DocumentManifestEntry } from "../schemas/documents";
7
8
 
8
9
  interface SectionRow {
9
10
  sectionId: string;
@@ -70,6 +71,17 @@ interface PendingMediaDeletionRow {
70
71
  deletedAt: string;
71
72
  }
72
73
 
74
+ /** A document staged locally and not yet committed. Git-tier rows carry the
75
+ * bytes (they ride /api/save); bucket-tier rows are manifest-only — their
76
+ * object is already in the private docs bucket. */
77
+ interface PendingDocumentRow {
78
+ id: string;
79
+ entry: DocumentManifestEntry;
80
+ blob: Blob | null;
81
+ localUrl: string;
82
+ updatedAt: string;
83
+ }
84
+
73
85
  export interface PreservedEditRow {
74
86
  id: string;
75
87
  sectionId: string;
@@ -88,6 +100,7 @@ class EditorDatabase extends Dexie {
88
100
  pendingMedia!: Dexie.Table<PendingMediaRow, string>;
89
101
  pendingMediaDeletions!: Dexie.Table<PendingMediaDeletionRow, string>;
90
102
  preservedEdits!: Dexie.Table<PreservedEditRow, string>;
103
+ pendingDocuments!: Dexie.Table<PendingDocumentRow, string>;
91
104
 
92
105
  constructor(siteId: string) {
93
106
  super(`brand-portal-${siteId}`);
@@ -190,6 +203,21 @@ class EditorDatabase extends Dexie {
190
203
  pendingMediaDeletions: "id",
191
204
  preservedEdits: "id, sectionId",
192
205
  });
206
+ // v10: pendingDocuments — locally staged document uploads. Additive store
207
+ // only; every existing table declaration is carried forward unchanged so
208
+ // no data is dropped on upgrade.
209
+ this.version(10).stores({
210
+ sections: "sectionId",
211
+ siteIndex: "key",
212
+ meta: "key",
213
+ siteConfig: "key",
214
+ contentCache: "key",
215
+ mediaManifest: "key",
216
+ pendingMedia: "id",
217
+ pendingMediaDeletions: "id",
218
+ preservedEdits: "id, sectionId",
219
+ pendingDocuments: "id",
220
+ });
193
221
  }
194
222
  }
195
223
 
@@ -593,3 +621,29 @@ export async function listPreservedEdits(): Promise<PreservedEditRow[]> {
593
621
  export async function removePreservedEdit(id: string): Promise<void> {
594
622
  await getDb().preservedEdits.delete(id);
595
623
  }
624
+
625
+ export interface PendingDocumentRecord {
626
+ id: string;
627
+ entry: DocumentManifestEntry;
628
+ blob: Blob | null;
629
+ localUrl: string;
630
+ }
631
+
632
+ export async function addPendingDocument(record: PendingDocumentRecord): Promise<void> {
633
+ await getDb().pendingDocuments.put({ ...record, updatedAt: new Date().toISOString() });
634
+ }
635
+
636
+ export async function getPendingDocuments(): Promise<PendingDocumentRecord[]> {
637
+ const rows = await getDb().pendingDocuments.toArray();
638
+ return rows.map(({ id, entry, blob, localUrl }) => ({ id, entry, blob, localUrl }));
639
+ }
640
+
641
+ export async function getPendingDocumentBlob(id: string): Promise<Blob | null> {
642
+ const row = await getDb().pendingDocuments.get(id);
643
+ return row?.blob ?? null;
644
+ }
645
+
646
+ export async function clearPendingDocumentsByIds(ids: string[]): Promise<void> {
647
+ if (ids.length === 0) return;
648
+ await getDb().pendingDocuments.bulkDelete(ids);
649
+ }
@@ -0,0 +1,83 @@
1
+ import type { Section } from "../schemas/sections";
2
+ import { getContainerChildren } from "./container-ops";
3
+
4
+ /**
5
+ * Structural walkers over section content for `document` references, the
6
+ * counterpart to `image-refs` for the document manifest: `tallyDocumentIds`
7
+ * feeds the media modal's usage pills, `clearDocumentRefs` repairs sections
8
+ * that pointed at a deleted document.
9
+ *
10
+ * Two differences from `image-refs`. The reference is identified by NODE TYPE
11
+ * (`type === "document"`, then `content.doc.documentId`) rather than by key
12
+ * name, so an unrelated field that happens to be called `doc` is never touched.
13
+ * And the walk recurses through container children structurally, deliberately
14
+ * without a depth cap: `MAX_BLOCK_DEPTH` currently makes depth 1 the only
15
+ * reachable nesting, but a counter that miscounts if that rule changes would
16
+ * silently under-report usage and leave dangling references behind.
17
+ */
18
+
19
+ interface DocumentSectionContent {
20
+ doc?: { documentId?: string };
21
+ }
22
+
23
+ /** The non-empty `documentId` this node references, or "" if it references none. */
24
+ function documentIdOf(section: Section): string {
25
+ if (section.type !== "document") return "";
26
+ return (section.content as DocumentSectionContent).doc?.documentId ?? "";
27
+ }
28
+
29
+ /**
30
+ * Count every non-empty `documentId` referenced by `value` — a section, or an
31
+ * array of them — including sections nested inside containers. Accepts a
32
+ * caller-supplied accumulator so a list of `LoadedSection`s can be tallied one
33
+ * `.section` at a time into a single map.
34
+ */
35
+ export function tallyDocumentIds(
36
+ value: Section | readonly Section[],
37
+ counts: Map<string, number> = new Map(),
38
+ ): Map<string, number> {
39
+ if (Array.isArray(value)) {
40
+ for (const item of value) tallyDocumentIds(item, counts);
41
+ return counts;
42
+ }
43
+ const section = value as Section;
44
+ const documentId = documentIdOf(section);
45
+ if (documentId) counts.set(documentId, (counts.get(documentId) ?? 0) + 1);
46
+ for (const child of getContainerChildren(section)) tallyDocumentIds(child, counts);
47
+ return counts;
48
+ }
49
+
50
+ /**
51
+ * Blank every `doc.documentId` in `ids` to "" (the deleted-media repair pass).
52
+ *
53
+ * Returns `changed: false` and the ORIGINAL section reference when nothing
54
+ * matched, so callers can skip a re-render and a dirty mark. A match on a
55
+ * nested child returns a rewritten CONTAINER — the container is the section
56
+ * that owns the child's bytes, so it is what gets dirtied and saved; untouched
57
+ * siblings are carried over by reference.
58
+ */
59
+ export function clearDocumentRefs(
60
+ section: Section,
61
+ ids: Set<string>,
62
+ ): { changed: boolean; section: Section } {
63
+ const documentId = documentIdOf(section);
64
+ if (documentId && ids.has(documentId)) {
65
+ const content = section.content as DocumentSectionContent;
66
+ return {
67
+ changed: true,
68
+ section: { ...section, content: { ...section.content, doc: { ...content.doc, documentId: "" } } },
69
+ };
70
+ }
71
+
72
+ const children = getContainerChildren(section);
73
+ if (children.length === 0) return { changed: false, section };
74
+
75
+ let changed = false;
76
+ const next = children.map((child) => {
77
+ const result = clearDocumentRefs(child, ids);
78
+ if (result.changed) changed = true;
79
+ return result.section;
80
+ });
81
+ if (!changed) return { changed: false, section };
82
+ return { changed: true, section: { ...section, content: { ...section.content, children: next } } };
83
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ DOCUMENT_BUCKET_MAX_BYTES,
3
+ DOCUMENT_GIT_MAX_BYTES,
4
+ DOCUMENT_MIME_TO_EXT,
5
+ documentGitPath as documentGitPathFromSlug,
6
+ documentBucketPath,
7
+ } from "../schemas/documents";
8
+ import { sanitizeMediaName } from "../media/utils";
9
+
10
+ // Bucket keys are already spelled once, in schemas/documents (S1) — re-exported
11
+ // so this module is the single import site for the whole upload flow.
12
+ export { documentBucketPath };
13
+
14
+ /** Every way a document upload can be refused. The first three are decidable
15
+ * client-side; `no_documents_store` only comes back from the upload-intent
16
+ * endpoint (the browser cannot see whether a docs bucket is configured). */
17
+ export type DocumentRejectionReason =
18
+ | "unsupported_type"
19
+ | "too_large"
20
+ | "too_small"
21
+ | "no_documents_store";
22
+
23
+ export type DocumentUploadPlan =
24
+ | { tier: "git"; kind: "pdf" | "html"; ext: "pdf" | "html" }
25
+ | { tier: "bucket"; kind: "pdf" | "html"; ext: "pdf" | "html" }
26
+ | { tier: "rejected"; reason: DocumentRejectionReason };
27
+
28
+ /** Pure tier routing. The bucket tier's lower bound is exactly the git cap, so
29
+ * there is no size that falls through both. */
30
+ export function planDocumentUpload(file: { name: string; size: number; type: string }): DocumentUploadPlan {
31
+ // `file.type` is an arbitrary string, so the const map is widened for the
32
+ // lookup rather than indexed with a cast that TS would reject.
33
+ const ext = (DOCUMENT_MIME_TO_EXT as Record<string, "pdf" | "html" | undefined>)[file.type];
34
+ if (!ext) return { tier: "rejected", reason: "unsupported_type" };
35
+ if (file.size <= 0) return { tier: "rejected", reason: "too_small" };
36
+ if (file.size > DOCUMENT_BUCKET_MAX_BYTES) return { tier: "rejected", reason: "too_large" };
37
+ if (file.size <= DOCUMENT_GIT_MAX_BYTES) return { tier: "git", kind: ext, ext };
38
+ return { tier: "bucket", kind: ext, ext };
39
+ }
40
+
41
+ /** Human copy for every rejection code, client- or server-originated. Never
42
+ * falls back to the media pipeline's generic size copy (an oversized PDF
43
+ * today gets "…is larger than the NMB media limit.", which names the wrong
44
+ * limit and no fix). */
45
+ export function documentRejectionMessage(reason: DocumentRejectionReason, filename: string): string {
46
+ switch (reason) {
47
+ case "unsupported_type":
48
+ return `"${filename}" — only PDF and HTML documents can be uploaded.`;
49
+ case "too_large":
50
+ return `"${filename}" is larger than the 200MB document limit.`;
51
+ case "too_small":
52
+ return `"${filename}" is empty.`;
53
+ case "no_documents_store":
54
+ return `"${filename}" is over 2.5MB, and document storage isn't set up on this site yet.`;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Repo path for a git-tier document, from the RAW file name. The layout itself
60
+ * lives once in schemas/documents (S1), which takes an already-sanitized stem;
61
+ * this wrapper only adds `sanitizeMediaName`, so the two can never drift. The
62
+ * extension comes from the explicit pdf/html map — never mimeToExt's "bin".
63
+ */
64
+ export function documentGitPath(originalName: string, hash: string, ext: "pdf" | "html"): string {
65
+ return documentGitPathFromSlug(sanitizeMediaName(originalName), hash, ext);
66
+ }
@@ -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
+ }
@@ -188,6 +188,22 @@ export function opTouchedSectionIds(ops: IndexOp[]): string[] {
188
188
  return [...ids];
189
189
  }
190
190
 
191
+ /**
192
+ * The ops that materialize and write the nav sidecar (the ones that set
193
+ * `navTouched` in applyIndexOps — keep the two in sync; the "nav sidecar
194
+ * preservation" test asserts they agree). Callers must refuse to apply these
195
+ * over a DEGRADED nav read: the tree they'd commit was built from only the
196
+ * entries that version could parse, so writing it destroys the rest.
197
+ * Section-level ops are absent by design — they emit no `nav` key at all.
198
+ */
199
+ const NAV_TOUCHING_OPS = new Set<IndexOp["op"]>([
200
+ "addPage", "removePage", "addNavGroup", "removeNavGroup", "setNavGroupMeta", "movePageInNav",
201
+ ]);
202
+
203
+ export function opsTouchNav(ops: IndexOp[]): boolean {
204
+ return ops.some((op) => NAV_TOUCHING_OPS.has(op.op));
205
+ }
206
+
191
207
  const fmt = (ids: string[]) => ids.map((id) => `"${id}"`).join(", ");
192
208
 
193
209
  /**
package/src/lib/loader.ts CHANGED
@@ -3,7 +3,7 @@ import { IndexSchema, type SiteIndex, type SectionMeta } from "../schemas/site-c
3
3
  import { getAllSchemas } from "./registry";
4
4
  import { blockDepth, MAX_BLOCK_DEPTH } from "../schemas/block";
5
5
  import { upgradeLegacySection } from "./migrate-sections-transform";
6
- import { NavFileSchema, type NavEntry } from "../schemas/nav";
6
+ import { NavEntrySchema, type NavEntry } from "../schemas/nav";
7
7
 
8
8
  export interface LoadedSection {
9
9
  section: Section;
@@ -13,21 +13,41 @@ export interface LoadedSection {
13
13
  export interface SiteContent {
14
14
  sections: LoadedSection[];
15
15
  index: SiteIndex;
16
+ /** True when nav.json was present but held entries this package version could
17
+ * not represent (unknown kinds, invalid shapes) — the returned nav is missing
18
+ * them. Writers must treat a degraded nav as read-only: wholesale-replacing
19
+ * nav.json from a degraded read permanently destroys the entries this
20
+ * version dropped. */
21
+ navDegraded: boolean;
16
22
  }
17
23
 
18
24
  /**
19
- * Parse the nav.json sidecar defensively. A malformed sidecar must degrade to a
20
- * flat nav, never brick the site — nav.json is optional content, and the whole
21
- * point of the sidecar is that it tolerates writers that don't understand it.
25
+ * Parse the nav.json sidecar defensively, entry by entry. A malformed sidecar
26
+ * must degrade, never brick the site — nav.json is optional content, and the
27
+ * whole point of the sidecar is that it tolerates writers that don't understand
28
+ * it. Tolerance is PER ENTRY: one unknown entry kind (a newer package version's
29
+ * addition, or an authoring typo) drops that entry alone, not every group on
30
+ * the site. Any drop is reported as `degraded` so write paths can refuse to
31
+ * persist the lossy tree back over the original.
22
32
  */
23
- function parseNavFile(navFile: unknown): NavEntry[] {
24
- if (!navFile) return [];
25
- const result = NavFileSchema.safeParse(navFile);
26
- if (!result.success) {
33
+ function parseNavFile(navFile: unknown): { entries: NavEntry[]; degraded: boolean } {
34
+ if (!navFile) return { entries: [], degraded: false };
35
+ const rawEntries = (navFile as { entries?: unknown }).entries;
36
+ if (!Array.isArray(rawEntries)) {
27
37
  console.warn("Invalid nav.json, falling back to a flat navigation");
28
- return [];
38
+ return { entries: [], degraded: true };
29
39
  }
30
- return result.data.entries;
40
+ const entries: NavEntry[] = [];
41
+ let dropped = 0;
42
+ for (const raw of rawEntries) {
43
+ const result = NavEntrySchema.safeParse(raw);
44
+ if (result.success) entries.push(result.data);
45
+ else dropped++;
46
+ }
47
+ if (dropped > 0) {
48
+ console.warn(`nav.json: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; navigation is degraded`);
49
+ }
50
+ return { entries, degraded: dropped > 0 };
31
51
  }
32
52
 
33
53
  /**
@@ -83,7 +103,8 @@ export function mergeSiteContent(
83
103
  }
84
104
  }
85
105
 
86
- return { sections, index: { ...index, nav: parseNavFile(navFile) } };
106
+ const nav = parseNavFile(navFile);
107
+ return { sections, index: { ...index, nav: nav.entries }, navDegraded: nav.degraded };
87
108
  }
88
109
 
89
110
  /**
@@ -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
- export type SiteNavEntry = ({ kind: "page" } & PageNavItem) | GroupNavItem;
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,
package/src/lib/pages.ts CHANGED
@@ -203,6 +203,54 @@ export function reorderSectionInPage(
203
203
  return { ...index, pages };
204
204
  }
205
205
 
206
+ /**
207
+ * Translate an insertion index expressed over the VISIBLE section list (what the
208
+ * editor renders: `page.order` filtered to loadable bodies) into a `page.order`
209
+ * position. The two disagree whenever a section body was dropped by the loader
210
+ * (unparseable file, schema miss, site-local type) — splicing a display index
211
+ * straight into `order` then moves the WRONG section. `displayIndex` may be
212
+ * `visibleIds.length` ("after the last visible entry"), which lands right after
213
+ * the last visible id so hidden trailing sections keep their place.
214
+ */
215
+ export function visibleInsertPosition(order: string[], visibleIds: string[], displayIndex: number): number {
216
+ if (displayIndex < visibleIds.length) {
217
+ const pos = order.indexOf(visibleIds[displayIndex]);
218
+ if (pos >= 0) return pos;
219
+ }
220
+ const lastVisible = visibleIds[visibleIds.length - 1];
221
+ const lastPos = lastVisible !== undefined ? order.indexOf(lastVisible) : -1;
222
+ return lastPos >= 0 ? lastPos + 1 : order.length;
223
+ }
224
+
225
+ /**
226
+ * Reorder a section using DISPLAY indices over the visible list (same semantics
227
+ * as `reorderSectionInPage`: `toIndex` is the post-removal position). Resolves
228
+ * both endpoints to ids before touching `page.order`, so filtered-out sections
229
+ * can neither be moved by mistake nor shift the splice onto a different entry.
230
+ * Falls back to a no-op when the endpoints don't resolve.
231
+ */
232
+ export function reorderVisibleSectionInPage(
233
+ index: SiteIndex,
234
+ pageId: string,
235
+ visibleIds: string[],
236
+ fromIndex: number,
237
+ toIndex: number,
238
+ ): SiteIndex {
239
+ const fromId = visibleIds[fromIndex];
240
+ if (fromId === undefined) return index;
241
+ const withoutFrom = visibleIds.filter((id) => id !== fromId);
242
+ const pages = index.pages.map((p) => {
243
+ if (p.id !== pageId) return p;
244
+ const fromPos = p.order.indexOf(fromId);
245
+ if (fromPos < 0) return p;
246
+ const order = [...p.order];
247
+ order.splice(fromPos, 1);
248
+ order.splice(visibleInsertPosition(order, withoutFrom, toIndex), 0, fromId);
249
+ return { ...p, order };
250
+ });
251
+ return { ...index, pages };
252
+ }
253
+
206
254
  export function moveSection(
207
255
  index: SiteIndex,
208
256
  sectionId: string,