Skip to content
File

Blob: src/sites/json-prewalk.ts

typescript92 lines
1import type { JSONContent } from "@tiptap/core";
2 
3const UPLOAD_PATH = /^\/uploads\/([A-Za-z0-9_-]+)$/;
4 
5export interface PreWalkMention {
6 reachable: boolean;
7}
8 
9export interface PreWalkOptions {
10 // The page that owns the rendered document. Image sources are rewritten to
11 // `/_assets/<pageId>/<uploadId>` so the asset gate can verify the
12 // (page, upload) pair on every visitor request.
13 pageId: string;
14 mentions: ReadonlyMap<string, PreWalkMention>;
15}
16 
17/**
18 * Pre-walks the Tiptap JSON before the static renderer runs:
19 * - rewrites `image.attrs.src` of the form `/uploads/<id>` to
20 * `/_assets/<pageId>/<id>` so visitors hit the asset gate (NOT the
21 * authenticated `/uploads/:id` route),
22 * - redacts `pageMention.attrs.pageId` for any mention whose target is not
23 * reachable in the current site's published set. The static renderer maps
24 * `pageId=null` to the restricted presentation, so `data-page-id` never
25 * leaks for redacted mentions.
26 *
27 * Mutates `content` in place and returns it for convenience.
28 */
29export function preWalkSitesJson(content: JSONContent, options: PreWalkOptions): JSONContent {
30 walk(content, options);
31 return content;
32}
33 
34function walk(node: JSONContent, options: PreWalkOptions): void {
35 if (!node || typeof node !== "object") return;
36 
37 if (node.type === "pageMention") {
38 const pageId = readString(node.attrs?.pageId);
39 if (pageId) {
40 const resolved = options.mentions.get(pageId);
41 if (!resolved || !resolved.reachable) {
42 if (node.attrs) {
43 node.attrs.pageId = null;
44 }
45 }
46 }
47 } else if (node.type === "image") {
48 const src = readString(node.attrs?.src);
49 if (src) {
50 const match = UPLOAD_PATH.exec(src);
51 if (match) {
52 node.attrs!.src = `/_assets/${options.pageId}/${match[1]}`;
53 }
54 }
55 }
56 
57 const children = node.content;
58 if (Array.isArray(children)) {
59 for (const child of children) {
60 walk(child, options);
61 }
62 }
63}
64 
65function readString(value: unknown): string | null {
66 return typeof value === "string" && value.length > 0 ? value : null;
67}
68 
69/**
70 * Collect every `pageMention.attrs.pageId` referenced anywhere in the
71 * document. Used to batch-resolve mentions against the publish set before
72 * the render walk runs.
73 */
74export function collectMentionPageIds(content: JSONContent): string[] {
75 const ids = new Set<string>();
76 collect(content, ids);
77 return [...ids];
78}
79 
80function collect(node: JSONContent, into: Set<string>): void {
81 if (!node || typeof node !== "object") return;
82 if (node.type === "pageMention") {
83 const pageId = readString(node.attrs?.pageId);
84 if (pageId) into.add(pageId);
85 }
86 if (Array.isArray(node.content)) {
87 for (const child of node.content) {
88 collect(child, into);
89 }
90 }
91}