@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.
Files changed (37) hide show
  1. package/README.md +6 -0
  2. package/dist/lib/html-parser/html-custom-blocks.js +15 -1
  3. package/dist/lib/html-parser/html-inline.d.ts +5 -16
  4. package/dist/lib/html-parser/html-inline.js +52 -103
  5. package/dist/lib/html-parser/html-parser.js +27 -51
  6. package/dist/lib/html-parser/html-tokenizer.d.ts +15 -0
  7. package/dist/lib/html-parser/html-tokenizer.js +91 -0
  8. package/dist/lib/markdown-parser/markdown-inline.d.ts +5 -0
  9. package/dist/lib/markdown-parser/markdown-inline.js +135 -0
  10. package/dist/lib/markdown-parser/markdown-parser.d.ts +3 -3
  11. package/dist/lib/markdown-parser/markdown-parser.js +20 -136
  12. package/dist/lib/parser/index.d.ts +1 -0
  13. package/dist/lib/parser/index.js +1 -0
  14. package/dist/lib/parser/parser-inline.d.ts +14 -0
  15. package/dist/lib/parser/parser-inline.js +122 -0
  16. package/dist/lib/parser/parser-sanitize.d.ts +2 -0
  17. package/dist/lib/parser/parser-sanitize.js +149 -0
  18. package/dist/lib/parser/parser.js +28 -238
  19. package/dist/lib/relations/relations-uri.d.ts +16 -0
  20. package/dist/lib/relations/relations-uri.js +39 -0
  21. package/dist/lib/relations/relations.d.ts +4 -17
  22. package/dist/lib/relations/relations.js +6 -41
  23. package/dist/lib/sanitize/sanitize-format.d.ts +12 -0
  24. package/dist/lib/sanitize/sanitize-format.js +158 -0
  25. package/dist/lib/sanitize/sanitize.d.ts +10 -10
  26. package/dist/lib/sanitize/sanitize.js +48 -118
  27. package/dist/lib/theme/theme-default.d.ts +2 -0
  28. package/dist/lib/theme/theme-default.js +119 -0
  29. package/dist/lib/theme/theme.d.ts +3 -1
  30. package/dist/lib/theme/theme.js +10 -118
  31. package/dist/types/custom.d.ts +19 -0
  32. package/dist/types/tiptap-inline.d.ts +1 -0
  33. package/dist/types/tiptap-media.d.ts +146 -0
  34. package/dist/types/tiptap-media.js +1 -0
  35. package/dist/types/tiptap.d.ts +3 -132
  36. package/dist/types/tiptap.js +1 -0
  37. 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 { resolveInternalLinkHref } from "../relations";
4
- import { resolveTiptapMediaUrl, sanitizeCssColor, sanitizeTextStyleAttrs, sanitizeTiptapIframeUrl, slugifyHeadingText, } from "../sanitize";
5
- const extractInlineText = (nodes) => (nodes ?? [])
6
- .map((n) => (n.type === "text" ? n.text : n.type === "linker" ? n.attrs.label || "" : ""))
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, TiptapInternalLinkResolver } from "../../types";
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 a Tiptap document.
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 { sanitizeTiptapUrl } from "../sanitize";
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 a Tiptap document.
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(doc);
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, TiptapTextStyleMarkAttrs } from "../../types";
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;