@vicacha-devs/astro-tiptap-blocks 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/lib/html-parser/html-custom-blocks.js +15 -1
- package/dist/lib/html-parser/html-inline.d.ts +5 -16
- package/dist/lib/html-parser/html-inline.js +52 -103
- package/dist/lib/html-parser/html-parser.js +27 -51
- package/dist/lib/html-parser/html-tokenizer.d.ts +15 -0
- package/dist/lib/html-parser/html-tokenizer.js +91 -0
- package/dist/lib/markdown-parser/markdown-inline.d.ts +5 -0
- package/dist/lib/markdown-parser/markdown-inline.js +135 -0
- package/dist/lib/markdown-parser/markdown-parser.d.ts +3 -3
- package/dist/lib/markdown-parser/markdown-parser.js +20 -136
- package/dist/lib/parser/index.d.ts +1 -0
- package/dist/lib/parser/index.js +1 -0
- package/dist/lib/parser/parser-inline.d.ts +14 -0
- package/dist/lib/parser/parser-inline.js +122 -0
- package/dist/lib/parser/parser-sanitize.d.ts +2 -0
- package/dist/lib/parser/parser-sanitize.js +149 -0
- package/dist/lib/parser/parser.js +28 -238
- package/dist/lib/relations/relations-uri.d.ts +16 -0
- package/dist/lib/relations/relations-uri.js +39 -0
- package/dist/lib/relations/relations.d.ts +4 -17
- package/dist/lib/relations/relations.js +6 -41
- package/dist/lib/sanitize/sanitize-format.d.ts +12 -0
- package/dist/lib/sanitize/sanitize-format.js +158 -0
- package/dist/lib/sanitize/sanitize.d.ts +10 -10
- package/dist/lib/sanitize/sanitize.js +48 -118
- package/dist/lib/theme/theme-default.d.ts +2 -0
- package/dist/lib/theme/theme-default.js +119 -0
- package/dist/lib/theme/theme.d.ts +3 -1
- package/dist/lib/theme/theme.js +10 -118
- package/dist/types/custom.d.ts +19 -0
- package/dist/types/tiptap-inline.d.ts +1 -0
- package/dist/types/tiptap-media.d.ts +146 -0
- package/dist/types/tiptap-media.js +1 -0
- package/dist/types/tiptap.d.ts +3 -132
- package/dist/types/tiptap.js +1 -0
- package/package.json +1 -1
|
@@ -1,253 +1,43 @@
|
|
|
1
1
|
import { parseTiptapHtml } from "../html-parser";
|
|
2
2
|
import { isLikelyMarkdown, parseTiptapMarkdown } from "../markdown-parser";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
.join("");
|
|
8
|
-
const sanitizeMarks = (marks, options) => {
|
|
9
|
-
if (!Array.isArray(marks) || marks.length === 0)
|
|
10
|
-
return undefined;
|
|
11
|
-
const result = [];
|
|
12
|
-
for (const mark of marks) {
|
|
13
|
-
if (!mark || typeof mark !== "object" || !("type" in mark))
|
|
14
|
-
continue;
|
|
15
|
-
if (mark.type === "link") {
|
|
16
|
-
result.push({
|
|
17
|
-
...mark,
|
|
18
|
-
attrs: {
|
|
19
|
-
...mark.attrs,
|
|
20
|
-
href: resolveInternalLinkHref({ href: mark.attrs?.href }, options?.resolveInternalLink),
|
|
21
|
-
},
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
else if (mark.type === "highlight") {
|
|
25
|
-
const color = sanitizeCssColor(mark.attrs?.color);
|
|
26
|
-
result.push({ type: "highlight", ...(color ? { attrs: { color } } : {}) });
|
|
27
|
-
}
|
|
28
|
-
else if (mark.type === "textStyle") {
|
|
29
|
-
const safeAttrs = sanitizeTextStyleAttrs(mark.attrs);
|
|
30
|
-
if (safeAttrs)
|
|
31
|
-
result.push({ type: "textStyle", attrs: safeAttrs });
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
result.push(mark);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return result.length > 0 ? result : undefined;
|
|
38
|
-
};
|
|
39
|
-
const sanitizeInlineNodes = (nodes, options) => {
|
|
40
|
-
if (!Array.isArray(nodes))
|
|
41
|
-
return [];
|
|
42
|
-
const result = [];
|
|
43
|
-
for (const node of nodes) {
|
|
44
|
-
if (!node || typeof node !== "object" || !("type" in node))
|
|
45
|
-
continue;
|
|
46
|
-
if (node.type === "text") {
|
|
47
|
-
const marks = sanitizeMarks(node.marks, options);
|
|
48
|
-
result.push({
|
|
49
|
-
type: "text",
|
|
50
|
-
text: typeof node.text === "string" ? node.text : "",
|
|
51
|
-
...(marks ? { marks } : {}),
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
else if (node.type === "hardBreak") {
|
|
55
|
-
result.push({ type: "hardBreak" });
|
|
56
|
-
}
|
|
57
|
-
else if (node.type === "linker") {
|
|
58
|
-
const href = resolveInternalLinkHref(node.attrs || {}, options?.resolveInternalLink);
|
|
59
|
-
result.push({ ...node, attrs: { ...node.attrs, href } });
|
|
60
|
-
}
|
|
61
|
-
else if (node.type === "mathematics") {
|
|
62
|
-
result.push({
|
|
63
|
-
type: "mathematics",
|
|
64
|
-
attrs: {
|
|
65
|
-
latex: String(node.attrs?.latex ?? ""),
|
|
66
|
-
displayMode: node.attrs?.displayMode ?? "inline",
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return result;
|
|
72
|
-
};
|
|
73
|
-
const sanitizeBlockNodes = (nodes, options) => {
|
|
74
|
-
if (!Array.isArray(nodes))
|
|
75
|
-
return [];
|
|
76
|
-
const result = [];
|
|
77
|
-
for (const node of nodes) {
|
|
78
|
-
if (!node || typeof node !== "object" || !("type" in node))
|
|
79
|
-
continue;
|
|
80
|
-
switch (node.type) {
|
|
81
|
-
case "heading": {
|
|
82
|
-
const content = sanitizeInlineNodes(node.content, options);
|
|
83
|
-
const level = (node.attrs?.level ?? 2);
|
|
84
|
-
const rawText = extractInlineText(content);
|
|
85
|
-
const id = node.attrs?.id || (rawText ? slugifyHeadingText(rawText) : null);
|
|
86
|
-
result.push({ ...node, attrs: { ...node.attrs, level, ...(id ? { id } : {}) }, content });
|
|
87
|
-
break;
|
|
88
|
-
}
|
|
89
|
-
case "paragraph":
|
|
90
|
-
case "leadParagraph":
|
|
91
|
-
result.push({ ...node, content: sanitizeInlineNodes(node.content, options) });
|
|
92
|
-
break;
|
|
93
|
-
case "blockquote":
|
|
94
|
-
case "informationPane":
|
|
95
|
-
case "conversationHistory":
|
|
96
|
-
result.push({ ...node, content: sanitizeBlockNodes(node.content, options) });
|
|
97
|
-
break;
|
|
98
|
-
case "details":
|
|
99
|
-
result.push({
|
|
100
|
-
...node,
|
|
101
|
-
content: (node.content ?? [])
|
|
102
|
-
.map((child) => {
|
|
103
|
-
if (child.type === "detailsSummary")
|
|
104
|
-
return {
|
|
105
|
-
type: "detailsSummary",
|
|
106
|
-
content: sanitizeInlineNodes(child.content, options),
|
|
107
|
-
};
|
|
108
|
-
if (child.type === "detailsContent")
|
|
109
|
-
return {
|
|
110
|
-
type: "detailsContent",
|
|
111
|
-
content: sanitizeBlockNodes(child.content, options),
|
|
112
|
-
};
|
|
113
|
-
return sanitizeBlockNodes([child], options)[0];
|
|
114
|
-
})
|
|
115
|
-
.filter(Boolean),
|
|
116
|
-
});
|
|
117
|
-
break;
|
|
118
|
-
case "layoutContainer":
|
|
119
|
-
result.push({
|
|
120
|
-
...node,
|
|
121
|
-
content: (node.content ?? []).map((col) => ({
|
|
122
|
-
type: "layoutColumn",
|
|
123
|
-
content: sanitizeBlockNodes(col.content, options),
|
|
124
|
-
})),
|
|
125
|
-
});
|
|
126
|
-
break;
|
|
127
|
-
case "bulletList":
|
|
128
|
-
case "orderedList":
|
|
129
|
-
result.push({
|
|
130
|
-
...node,
|
|
131
|
-
content: (node.content ?? []).map((item) => ({
|
|
132
|
-
type: "listItem",
|
|
133
|
-
content: sanitizeBlockNodes(item.content, options),
|
|
134
|
-
})),
|
|
135
|
-
});
|
|
136
|
-
break;
|
|
137
|
-
case "taskList":
|
|
138
|
-
result.push({
|
|
139
|
-
...node,
|
|
140
|
-
content: (node.content ?? []).map((item) => ({
|
|
141
|
-
type: "taskItem",
|
|
142
|
-
attrs: { checked: Boolean(item.attrs?.checked) },
|
|
143
|
-
content: sanitizeBlockNodes(item.content, options),
|
|
144
|
-
})),
|
|
145
|
-
});
|
|
146
|
-
break;
|
|
147
|
-
case "codeBlock":
|
|
148
|
-
result.push({
|
|
149
|
-
...node,
|
|
150
|
-
content: sanitizeInlineNodes(node.content, options).filter((n) => n.type === "text"),
|
|
151
|
-
});
|
|
152
|
-
break;
|
|
153
|
-
case "image":
|
|
154
|
-
case "video":
|
|
155
|
-
case "audio": {
|
|
156
|
-
const safeSrc = resolveTiptapMediaUrl(node.attrs?.src, options);
|
|
157
|
-
if (safeSrc !== "#")
|
|
158
|
-
result.push({ ...node, attrs: { ...node.attrs, src: safeSrc } });
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
case "attachment": {
|
|
162
|
-
const safeHref = resolveTiptapMediaUrl(node.attrs?.href, options);
|
|
163
|
-
if (safeHref !== "#")
|
|
164
|
-
result.push({ ...node, attrs: { ...node.attrs, href: safeHref } });
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
case "iframe": {
|
|
168
|
-
const safeSrc = sanitizeTiptapIframeUrl(node.attrs?.src, options?.allowedIframeDomains);
|
|
169
|
-
if (safeSrc !== "#")
|
|
170
|
-
result.push({ ...node, attrs: { ...node.attrs, src: safeSrc } });
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
case "mathematics":
|
|
174
|
-
result.push({
|
|
175
|
-
type: "mathematics",
|
|
176
|
-
attrs: {
|
|
177
|
-
latex: String(node.attrs?.latex ?? ""),
|
|
178
|
-
displayMode: node.attrs?.displayMode ?? "block",
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
break;
|
|
182
|
-
case "tableOfContents":
|
|
183
|
-
result.push({ ...node });
|
|
184
|
-
break;
|
|
185
|
-
case "pageBreak":
|
|
186
|
-
case "horizontalRule":
|
|
187
|
-
result.push({ type: node.type });
|
|
188
|
-
break;
|
|
189
|
-
case "table":
|
|
190
|
-
result.push({
|
|
191
|
-
...node,
|
|
192
|
-
content: (node.content ?? []).map((row) => ({
|
|
193
|
-
type: "tableRow",
|
|
194
|
-
content: (row.content ?? []).map((cell) => ({
|
|
195
|
-
...cell,
|
|
196
|
-
attrs: {
|
|
197
|
-
...cell.attrs,
|
|
198
|
-
backgroundColor: sanitizeCssColor(cell.attrs?.backgroundColor),
|
|
199
|
-
},
|
|
200
|
-
content: sanitizeBlockNodes(cell.content, options),
|
|
201
|
-
})),
|
|
202
|
-
})),
|
|
203
|
-
});
|
|
204
|
-
break;
|
|
205
|
-
default: {
|
|
206
|
-
const custom = node;
|
|
207
|
-
if (typeof custom.type === "string" && custom.type.trim()) {
|
|
208
|
-
const safeAttrs = {};
|
|
209
|
-
let hasUnsafeUrl = false;
|
|
210
|
-
for (const [k, v] of Object.entries(custom.attrs ?? {})) {
|
|
211
|
-
if (typeof v === "string" && (/^(src|href)$/i.test(k) || k.endsWith("Url"))) {
|
|
212
|
-
const resolved = resolveTiptapMediaUrl(v, options);
|
|
213
|
-
if (resolved === "#") {
|
|
214
|
-
hasUnsafeUrl = true;
|
|
215
|
-
break;
|
|
216
|
-
}
|
|
217
|
-
safeAttrs[k] = resolved;
|
|
218
|
-
}
|
|
219
|
-
else
|
|
220
|
-
safeAttrs[k] = v;
|
|
221
|
-
}
|
|
222
|
-
if (!hasUnsafeUrl) {
|
|
223
|
-
result.push({
|
|
224
|
-
type: custom.type,
|
|
225
|
-
...(custom.attrs ? { attrs: safeAttrs } : {}),
|
|
226
|
-
...(Array.isArray(custom.content)
|
|
227
|
-
? { content: sanitizeBlockNodes(custom.content, options) }
|
|
228
|
-
: {}),
|
|
229
|
-
});
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
break;
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
return result;
|
|
237
|
-
};
|
|
238
|
-
const populateTocBlocks = (blocks) => {
|
|
3
|
+
import { slugifyHeadingText } from "../sanitize";
|
|
4
|
+
import { extractInlineText } from "./parser-inline";
|
|
5
|
+
import { sanitizeBlockNodes } from "./parser-sanitize";
|
|
6
|
+
const collectTocHeadings = (blocks) => {
|
|
239
7
|
const headings = [];
|
|
240
|
-
for (const b of blocks) {
|
|
8
|
+
for (const b of blocks ?? []) {
|
|
241
9
|
if (b.type === "heading") {
|
|
242
10
|
const text = extractInlineText(b.content).trim();
|
|
243
|
-
if (text)
|
|
11
|
+
if (text) {
|
|
244
12
|
headings.push({
|
|
245
13
|
id: b.attrs?.id || slugifyHeadingText(text),
|
|
246
14
|
text,
|
|
247
15
|
level: b.attrs?.level ?? 2,
|
|
248
16
|
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else if (b.type === "blockquote" ||
|
|
20
|
+
b.type === "informationPane" ||
|
|
21
|
+
b.type === "conversationHistory") {
|
|
22
|
+
headings.push(...collectTocHeadings(b.content));
|
|
23
|
+
}
|
|
24
|
+
else if (b.type === "layoutContainer") {
|
|
25
|
+
for (const col of b.content ?? [])
|
|
26
|
+
headings.push(...collectTocHeadings(col.content));
|
|
27
|
+
}
|
|
28
|
+
else if (b.type === "details") {
|
|
29
|
+
for (const child of b.content ?? []) {
|
|
30
|
+
if (child.type === "detailsContent")
|
|
31
|
+
headings.push(...collectTocHeadings(child.content));
|
|
32
|
+
else if (child.type !== "detailsSummary")
|
|
33
|
+
headings.push(...collectTocHeadings([child]));
|
|
34
|
+
}
|
|
249
35
|
}
|
|
250
36
|
}
|
|
37
|
+
return headings;
|
|
38
|
+
};
|
|
39
|
+
const populateTocBlocks = (blocks) => {
|
|
40
|
+
const headings = collectTocHeadings(blocks);
|
|
251
41
|
return blocks.map((b) => b.type === "tableOfContents" && (!b.attrs?.headings || b.attrs.headings.length === 0)
|
|
252
42
|
? { ...b, attrs: { ...b.attrs, headings } }
|
|
253
43
|
: b);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TiptapInternalLinkResolver } from "../../types";
|
|
2
|
+
export declare const parseInternalLinkUri: (href: string | null | undefined) => {
|
|
3
|
+
collectionName: string;
|
|
4
|
+
documentId: string;
|
|
5
|
+
} | null;
|
|
6
|
+
export declare const formatInternalLinkUri: (collectionName: string, documentId: string) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves an internal link URI (`tiptap://collectionName/documentId` or `linker` attributes) into a safe frontend href.
|
|
9
|
+
* Uses `resolveInternalLink` when provided, or defaults to `/<shortCollection>/<documentId>`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const resolveInternalLinkHref: (input: {
|
|
12
|
+
href?: string | null;
|
|
13
|
+
collectionName?: string | null;
|
|
14
|
+
documentId?: string | null;
|
|
15
|
+
label?: string | null;
|
|
16
|
+
}, resolver?: TiptapInternalLinkResolver) => string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { sanitizeTiptapUrl } from "../sanitize";
|
|
2
|
+
const INTERNAL_URI_RE = /^(?:tiptap|internal|cms|doc):\/\/([a-zA-Z0-9_.:-]+)\/([a-zA-Z0-9_.:-]+)(?:\?.*)?$/;
|
|
3
|
+
export const parseInternalLinkUri = (href) => {
|
|
4
|
+
if (!href || typeof href !== "string")
|
|
5
|
+
return null;
|
|
6
|
+
const match = INTERNAL_URI_RE.exec(href.trim());
|
|
7
|
+
if (!match)
|
|
8
|
+
return null;
|
|
9
|
+
return { collectionName: match[1], documentId: match[2] };
|
|
10
|
+
};
|
|
11
|
+
export const formatInternalLinkUri = (collectionName, documentId) => `tiptap://${collectionName.trim()}/${documentId.trim()}`;
|
|
12
|
+
/**
|
|
13
|
+
* Resolves an internal link URI (`tiptap://collectionName/documentId` or `linker` attributes) into a safe frontend href.
|
|
14
|
+
* Uses `resolveInternalLink` when provided, or defaults to `/<shortCollection>/<documentId>`.
|
|
15
|
+
*/
|
|
16
|
+
export const resolveInternalLinkHref = (input, resolver) => {
|
|
17
|
+
const parsedFromHref = parseInternalLinkUri(input.href);
|
|
18
|
+
const collectionName = (input.collectionName || parsedFromHref?.collectionName || "").trim();
|
|
19
|
+
const documentId = (input.documentId || parsedFromHref?.documentId || "").trim();
|
|
20
|
+
if (collectionName && documentId) {
|
|
21
|
+
const rawUri = formatInternalLinkUri(collectionName, documentId);
|
|
22
|
+
if (resolver) {
|
|
23
|
+
return sanitizeTiptapUrl(resolver({
|
|
24
|
+
collectionName,
|
|
25
|
+
documentId,
|
|
26
|
+
href: rawUri,
|
|
27
|
+
label: input.label,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
if (input.href && !/^(?:tiptap|internal|cms|doc):\/\//.test(input.href.trim())) {
|
|
31
|
+
return sanitizeTiptapUrl(input.href);
|
|
32
|
+
}
|
|
33
|
+
const shortCollection = collectionName.includes(".")
|
|
34
|
+
? collectionName.split(".").pop() || collectionName
|
|
35
|
+
: collectionName.replace(/^api::/, "");
|
|
36
|
+
return sanitizeTiptapUrl(`/${shortCollection}/${documentId}`);
|
|
37
|
+
}
|
|
38
|
+
return sanitizeTiptapUrl(input.href);
|
|
39
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { TiptapContentInput
|
|
1
|
+
import type { TiptapContentInput } from "../../types";
|
|
2
|
+
import { formatInternalLinkUri, parseInternalLinkUri, resolveInternalLinkHref } from "./relations-uri";
|
|
3
|
+
export { formatInternalLinkUri, parseInternalLinkUri, resolveInternalLinkHref };
|
|
2
4
|
export type TiptapMediaNodeType = "image" | "video" | "audio" | "attachment";
|
|
3
5
|
export interface ExtractedMediaReference {
|
|
4
6
|
nodeType: TiptapMediaNodeType;
|
|
@@ -21,23 +23,8 @@ export interface ExtractedRelationsResult {
|
|
|
21
23
|
documentId: string;
|
|
22
24
|
}>>;
|
|
23
25
|
}
|
|
24
|
-
export declare const parseInternalLinkUri: (href: string | null | undefined) => {
|
|
25
|
-
collectionName: string;
|
|
26
|
-
documentId: string;
|
|
27
|
-
} | null;
|
|
28
|
-
export declare const formatInternalLinkUri: (collectionName: string, documentId: string) => string;
|
|
29
|
-
/**
|
|
30
|
-
* Resolves an internal link URI (`tiptap://collectionName/documentId` or `linker` attributes) into a safe frontend href.
|
|
31
|
-
* Uses `resolveInternalLink` when provided, or defaults to `/<shortCollection>/<documentId>`.
|
|
32
|
-
*/
|
|
33
|
-
export declare const resolveInternalLinkHref: (input: {
|
|
34
|
-
href?: string | null;
|
|
35
|
-
collectionName?: string | null;
|
|
36
|
-
documentId?: string | null;
|
|
37
|
-
label?: string | null;
|
|
38
|
-
}, resolver?: TiptapInternalLinkResolver) => string;
|
|
39
26
|
/**
|
|
40
27
|
* Extracts all referenced media assets (`image`, `video`, `audio`, `attachment`)
|
|
41
|
-
* and internal links (`linker` nodes and internal `tiptap://` link marks) from
|
|
28
|
+
* and internal links (`linker` nodes and internal `tiptap://` link marks) from any Tiptap document input.
|
|
42
29
|
*/
|
|
43
30
|
export declare const extractRelationsFromTiptap: (doc: TiptapContentInput | unknown) => ExtractedRelationsResult;
|
|
@@ -1,48 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseTiptapContent } from "../parser";
|
|
2
|
+
import { formatInternalLinkUri, parseInternalLinkUri, resolveInternalLinkHref, } from "./relations-uri";
|
|
3
|
+
export { formatInternalLinkUri, parseInternalLinkUri, resolveInternalLinkHref };
|
|
2
4
|
const MEDIA_NODE_TYPES = new Set(["image", "video", "audio", "attachment"]);
|
|
3
|
-
const INTERNAL_URI_RE = /^(?:tiptap|internal|cms|doc):\/\/([a-zA-Z0-9_.:-]+)\/([a-zA-Z0-9_.:-]+)(?:\?.*)?$/;
|
|
4
|
-
export const parseInternalLinkUri = (href) => {
|
|
5
|
-
if (!href || typeof href !== "string")
|
|
6
|
-
return null;
|
|
7
|
-
const match = INTERNAL_URI_RE.exec(href.trim());
|
|
8
|
-
if (!match)
|
|
9
|
-
return null;
|
|
10
|
-
return { collectionName: match[1], documentId: match[2] };
|
|
11
|
-
};
|
|
12
|
-
export const formatInternalLinkUri = (collectionName, documentId) => `tiptap://${collectionName.trim()}/${documentId.trim()}`;
|
|
13
|
-
/**
|
|
14
|
-
* Resolves an internal link URI (`tiptap://collectionName/documentId` or `linker` attributes) into a safe frontend href.
|
|
15
|
-
* Uses `resolveInternalLink` when provided, or defaults to `/<shortCollection>/<documentId>`.
|
|
16
|
-
*/
|
|
17
|
-
export const resolveInternalLinkHref = (input, resolver) => {
|
|
18
|
-
const parsedFromHref = parseInternalLinkUri(input.href);
|
|
19
|
-
const collectionName = (input.collectionName || parsedFromHref?.collectionName || "").trim();
|
|
20
|
-
const documentId = (input.documentId || parsedFromHref?.documentId || "").trim();
|
|
21
|
-
if (collectionName && documentId) {
|
|
22
|
-
const rawUri = formatInternalLinkUri(collectionName, documentId);
|
|
23
|
-
if (resolver) {
|
|
24
|
-
return sanitizeTiptapUrl(resolver({
|
|
25
|
-
collectionName,
|
|
26
|
-
documentId,
|
|
27
|
-
href: rawUri,
|
|
28
|
-
label: input.label,
|
|
29
|
-
}));
|
|
30
|
-
}
|
|
31
|
-
if (input.href && !/^(?:tiptap|internal|cms|doc):\/\//.test(input.href.trim())) {
|
|
32
|
-
return sanitizeTiptapUrl(input.href);
|
|
33
|
-
}
|
|
34
|
-
const shortCollection = collectionName.includes(".")
|
|
35
|
-
? collectionName.split(".").pop() || collectionName
|
|
36
|
-
: collectionName.replace(/^api::/, "");
|
|
37
|
-
return sanitizeTiptapUrl(`/${shortCollection}/${documentId}`);
|
|
38
|
-
}
|
|
39
|
-
return sanitizeTiptapUrl(input.href);
|
|
40
|
-
};
|
|
41
5
|
/**
|
|
42
6
|
* Extracts all referenced media assets (`image`, `video`, `audio`, `attachment`)
|
|
43
|
-
* and internal links (`linker` nodes and internal `tiptap://` link marks) from
|
|
7
|
+
* and internal links (`linker` nodes and internal `tiptap://` link marks) from any Tiptap document input.
|
|
44
8
|
*/
|
|
45
9
|
export const extractRelationsFromTiptap = (doc) => {
|
|
10
|
+
const normalizedRoot = typeof doc === "string" ? parseTiptapContent(doc) : doc;
|
|
46
11
|
const mediaIdsSet = new Set();
|
|
47
12
|
const mediaDocIdsSet = new Set();
|
|
48
13
|
const mediaAssets = [];
|
|
@@ -123,7 +88,7 @@ export const extractRelationsFromTiptap = (doc) => {
|
|
|
123
88
|
if (Array.isArray(n.content))
|
|
124
89
|
n.content.forEach(walk);
|
|
125
90
|
};
|
|
126
|
-
walk(
|
|
91
|
+
walk(normalizedRoot);
|
|
127
92
|
const internalLinks = Array.from(internalLinksMap.values());
|
|
128
93
|
const linksByCollection = {};
|
|
129
94
|
for (const link of internalLinks) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TiptapTextStyleMarkAttrs } from "../../types";
|
|
2
|
+
export declare const DEFAULT_ALLOWED_IFRAME_DOMAINS: ReadonlyArray<string>;
|
|
3
|
+
export declare const normalizeEmbedUrl: (url: string) => string;
|
|
4
|
+
export declare const isTrustedMediaEmbedUrl: (url: string | null | undefined) => boolean;
|
|
5
|
+
export declare const sanitizeCssColor: (color: string | null | undefined) => string | null;
|
|
6
|
+
export declare const sanitizeCssDimension: (val: string | null | undefined) => string | null;
|
|
7
|
+
export declare const sanitizeCssFontFamily: (font: string | null | undefined) => string | null;
|
|
8
|
+
export declare const sanitizeTextStyleAttrs: (attrs?: TiptapTextStyleMarkAttrs) => TiptapTextStyleMarkAttrs | undefined;
|
|
9
|
+
export declare const buildSafeInlineStyle: (attrs?: TiptapTextStyleMarkAttrs) => string | undefined;
|
|
10
|
+
export declare const slugifyHeadingText: (text: string) => string;
|
|
11
|
+
export declare const formatFileSize: (bytes: number | string | null | undefined) => string;
|
|
12
|
+
export declare const formatLatexPreview: (latex: string | null | undefined) => string;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export const DEFAULT_ALLOWED_IFRAME_DOMAINS = [
|
|
2
|
+
"youtube.com",
|
|
3
|
+
"youtube-nocookie.com",
|
|
4
|
+
"youtu.be",
|
|
5
|
+
"vimeo.com",
|
|
6
|
+
"player.vimeo.com",
|
|
7
|
+
"open.spotify.com",
|
|
8
|
+
"w.soundcloud.com",
|
|
9
|
+
"codepen.io",
|
|
10
|
+
"codesandbox.io",
|
|
11
|
+
"stackblitz.com",
|
|
12
|
+
"figma.com",
|
|
13
|
+
"loom.com",
|
|
14
|
+
"openstreetmap.org",
|
|
15
|
+
"google.com",
|
|
16
|
+
"maps.google.com",
|
|
17
|
+
];
|
|
18
|
+
export const normalizeEmbedUrl = (url) => {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = new URL(url);
|
|
21
|
+
const host = parsed.hostname.toLowerCase();
|
|
22
|
+
if ((host === "youtube.com" || host === "www.youtube.com") && parsed.pathname === "/watch") {
|
|
23
|
+
const videoId = parsed.searchParams.get("v");
|
|
24
|
+
if (videoId && /^[\w-]{6,20}$/.test(videoId)) {
|
|
25
|
+
return `https://www.youtube.com/embed/${videoId}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (host === "youtu.be" && /^\/[\w-]{6,20}$/.test(parsed.pathname)) {
|
|
29
|
+
return `https://www.youtube.com/embed${parsed.pathname}`;
|
|
30
|
+
}
|
|
31
|
+
if ((host === "vimeo.com" || host === "www.vimeo.com") && /^\/\d+$/.test(parsed.pathname)) {
|
|
32
|
+
return `https://player.vimeo.com/video${parsed.pathname}`;
|
|
33
|
+
}
|
|
34
|
+
return parsed.toString();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
export const isTrustedMediaEmbedUrl = (url) => {
|
|
41
|
+
if (!url || url === "#")
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
45
|
+
return DEFAULT_ALLOWED_IFRAME_DOMAINS.some((domain) => host === domain || host.endsWith(`.${domain}`));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const SAFE_COLOR_REGEX = /^(#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla)\(\s*[\d.,%\s/]+\)|var\(--[a-zA-Z0-9_-]+\)|[a-zA-Z]{3,20})$/;
|
|
52
|
+
const SAFE_DIMENSION_REGEX = /^\d+(?:\.\d+)?(?:px|rem|em|%|pt|ch|vh|vw)?$/;
|
|
53
|
+
const SAFE_FONT_FAMILY_REGEX = /^[a-zA-Z0-9\s,'"-]+$/;
|
|
54
|
+
export const sanitizeCssColor = (color) => {
|
|
55
|
+
if (!color || typeof color !== "string")
|
|
56
|
+
return null;
|
|
57
|
+
const trimmed = color.trim();
|
|
58
|
+
return SAFE_COLOR_REGEX.test(trimmed) ? trimmed : null;
|
|
59
|
+
};
|
|
60
|
+
export const sanitizeCssDimension = (val) => {
|
|
61
|
+
if (!val || typeof val !== "string")
|
|
62
|
+
return null;
|
|
63
|
+
const trimmed = val.trim();
|
|
64
|
+
return SAFE_DIMENSION_REGEX.test(trimmed) ? trimmed : null;
|
|
65
|
+
};
|
|
66
|
+
export const sanitizeCssFontFamily = (font) => {
|
|
67
|
+
if (!font || typeof font !== "string")
|
|
68
|
+
return null;
|
|
69
|
+
const trimmed = font.trim();
|
|
70
|
+
return SAFE_FONT_FAMILY_REGEX.test(trimmed) && !/url\(|expression\(|;/i.test(trimmed)
|
|
71
|
+
? trimmed
|
|
72
|
+
: null;
|
|
73
|
+
};
|
|
74
|
+
export const sanitizeTextStyleAttrs = (attrs) => {
|
|
75
|
+
if (!attrs)
|
|
76
|
+
return undefined;
|
|
77
|
+
const color = sanitizeCssColor(attrs.color);
|
|
78
|
+
const fontFamily = sanitizeCssFontFamily(attrs.fontFamily);
|
|
79
|
+
const fontSize = sanitizeCssDimension(attrs.fontSize);
|
|
80
|
+
const lineHeight = sanitizeCssDimension(attrs.lineHeight);
|
|
81
|
+
if (!color && !fontFamily && !fontSize && !lineHeight)
|
|
82
|
+
return undefined;
|
|
83
|
+
return {
|
|
84
|
+
...(color ? { color } : {}),
|
|
85
|
+
...(fontFamily ? { fontFamily } : {}),
|
|
86
|
+
...(fontSize ? { fontSize } : {}),
|
|
87
|
+
...(lineHeight ? { lineHeight } : {}),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
export const buildSafeInlineStyle = (attrs) => {
|
|
91
|
+
const safe = sanitizeTextStyleAttrs(attrs);
|
|
92
|
+
if (!safe)
|
|
93
|
+
return undefined;
|
|
94
|
+
const parts = [];
|
|
95
|
+
if (safe.color)
|
|
96
|
+
parts.push(`color: ${safe.color}`);
|
|
97
|
+
if (safe.fontFamily)
|
|
98
|
+
parts.push(`font-family: ${safe.fontFamily}`);
|
|
99
|
+
if (safe.fontSize)
|
|
100
|
+
parts.push(`font-size: ${safe.fontSize}`);
|
|
101
|
+
if (safe.lineHeight)
|
|
102
|
+
parts.push(`line-height: ${safe.lineHeight}`);
|
|
103
|
+
return parts.length > 0 ? parts.join("; ") : undefined;
|
|
104
|
+
};
|
|
105
|
+
export const slugifyHeadingText = (text) => text
|
|
106
|
+
.toLowerCase()
|
|
107
|
+
.trim()
|
|
108
|
+
.replace(/[^\w\s-]/g, "")
|
|
109
|
+
.replace(/[\s_-]+/g, "-")
|
|
110
|
+
.replace(/^-+|-+$/g, "") || "section";
|
|
111
|
+
export const formatFileSize = (bytes) => {
|
|
112
|
+
const num = typeof bytes === "string" ? Number(bytes) : bytes;
|
|
113
|
+
if (!num || Number.isNaN(num) || num <= 0)
|
|
114
|
+
return "";
|
|
115
|
+
if (num < 1024)
|
|
116
|
+
return `${num} B`;
|
|
117
|
+
if (num < 1024 * 1024)
|
|
118
|
+
return `${(num / 1024).toFixed(1)} KB`;
|
|
119
|
+
return `${(num / (1024 * 1024)).toFixed(1)} MB`;
|
|
120
|
+
};
|
|
121
|
+
const LATEX_SYMBOLS = [
|
|
122
|
+
[/\\alpha(?![a-zA-Z])/g, "α"],
|
|
123
|
+
[/\\beta(?![a-zA-Z])/g, "β"],
|
|
124
|
+
[/\\gamma(?![a-zA-Z])/g, "γ"],
|
|
125
|
+
[/\\delta(?![a-zA-Z])/g, "δ"],
|
|
126
|
+
[/\\pi(?![a-zA-Z])/g, "π"],
|
|
127
|
+
[/\\theta(?![a-zA-Z])/g, "θ"],
|
|
128
|
+
[/\\lambda(?![a-zA-Z])/g, "λ"],
|
|
129
|
+
[/\\sigma(?![a-zA-Z])/g, "σ"],
|
|
130
|
+
[/\\omega(?![a-zA-Z])/g, "ω"],
|
|
131
|
+
[/\\infty(?![a-zA-Z])/g, "∞"],
|
|
132
|
+
[/\\sum(?![a-zA-Z])/g, "∑"],
|
|
133
|
+
[/\\prod(?![a-zA-Z])/g, "∏"],
|
|
134
|
+
[/\\int(?![a-zA-Z])/g, "∫"],
|
|
135
|
+
[/\\sqrt\{([^}]+)\}/g, "√($1)"],
|
|
136
|
+
[/\\sqrt(?![a-zA-Z])/g, "√"],
|
|
137
|
+
[/\\frac\{([^}]+)\}\{([^}]+)\}/g, "($1)/($2)"],
|
|
138
|
+
[/\\pm(?![a-zA-Z])/g, "±"],
|
|
139
|
+
[/\\times(?![a-zA-Z])/g, "×"],
|
|
140
|
+
[/\\div(?![a-zA-Z])/g, "÷"],
|
|
141
|
+
[/\\cdot(?![a-zA-Z])/g, "·"],
|
|
142
|
+
[/\\neq(?![a-zA-Z])/g, "≠"],
|
|
143
|
+
[/\\leq(?![a-zA-Z])/g, "≤"],
|
|
144
|
+
[/\\geq(?![a-zA-Z])/g, "≥"],
|
|
145
|
+
[/\\approx(?![a-zA-Z])/g, "≈"],
|
|
146
|
+
[/\\rightarrow(?![a-zA-Z])|\\to(?![a-zA-Z])/g, "→"],
|
|
147
|
+
[/\\leftarrow(?![a-zA-Z])/g, "←"],
|
|
148
|
+
[/\\Rightarrow(?![a-zA-Z])/g, "⇒"],
|
|
149
|
+
];
|
|
150
|
+
export const formatLatexPreview = (latex) => {
|
|
151
|
+
if (!latex)
|
|
152
|
+
return "";
|
|
153
|
+
let result = latex.trim();
|
|
154
|
+
for (const [pattern, replacement] of LATEX_SYMBOLS) {
|
|
155
|
+
result = result.replace(pattern, replacement);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
};
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { TiptapMediaUrlResolver
|
|
1
|
+
import type { TiptapMediaUrlResolver } from "../../types";
|
|
2
|
+
export * from "./sanitize-format";
|
|
2
3
|
export declare const decodeHtmlEntities: (text: string) => string;
|
|
3
4
|
/**
|
|
4
5
|
* Validates and sanitizes a URL for safe rendering in `href` or `src` attributes.
|
|
5
|
-
* Blocks `javascript:`, `vbscript:`, `data:text/html`, `data:image/svg+xml`, and `//` protocol-relative URLs.
|
|
6
|
+
* Blocks `javascript:`, `vbscript:`, `data:text/html`, `data:image/svg+xml`, and `//` or `/\` protocol-relative URLs.
|
|
6
7
|
*/
|
|
7
8
|
export declare const sanitizeTiptapUrl: (rawUrl: string | null | undefined) => string;
|
|
8
9
|
/**
|
|
@@ -13,15 +14,14 @@ export declare const resolveTiptapMediaUrl: (rawUrl: string | null | undefined,
|
|
|
13
14
|
mediaBaseUrl?: string;
|
|
14
15
|
resolveMediaUrl?: TiptapMediaUrlResolver;
|
|
15
16
|
}) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Sanitizes a responsive `srcset` attribute string by validating and resolving every candidate URL.
|
|
19
|
+
*/
|
|
20
|
+
export declare const sanitizeTiptapSrcset: (rawSrcset: string | null | undefined, options?: {
|
|
21
|
+
mediaBaseUrl?: string;
|
|
22
|
+
resolveMediaUrl?: TiptapMediaUrlResolver;
|
|
23
|
+
}) => string | undefined;
|
|
16
24
|
/**
|
|
17
25
|
* Enforces HTTPS-only URLs and optional domain allowlist for sandboxed `<iframe>` embeds.
|
|
18
26
|
*/
|
|
19
27
|
export declare const sanitizeTiptapIframeUrl: (rawUrl: string | null | undefined, allowedDomains?: ReadonlyArray<string>) => string;
|
|
20
|
-
export declare const sanitizeCssColor: (color: string | null | undefined) => string | null;
|
|
21
|
-
export declare const sanitizeCssDimension: (val: string | null | undefined) => string | null;
|
|
22
|
-
export declare const sanitizeCssFontFamily: (font: string | null | undefined) => string | null;
|
|
23
|
-
export declare const sanitizeTextStyleAttrs: (attrs?: TiptapTextStyleMarkAttrs) => TiptapTextStyleMarkAttrs | undefined;
|
|
24
|
-
export declare const buildSafeInlineStyle: (attrs?: TiptapTextStyleMarkAttrs) => string | undefined;
|
|
25
|
-
export declare const slugifyHeadingText: (text: string) => string;
|
|
26
|
-
export declare const formatFileSize: (bytes: number | string | null | undefined) => string;
|
|
27
|
-
export declare const formatLatexPreview: (latex: string | null | undefined) => string;
|