@vicacha-devs/astro-tiptap-blocks 0.0.0-stage → 1.2.0
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/LICENSE +21 -0
- package/README.md +167 -2
- package/dist/lib/html-parser/html-custom-blocks.d.ts +4 -0
- package/dist/lib/html-parser/html-custom-blocks.js +162 -0
- package/dist/lib/html-parser/html-inline.d.ts +17 -0
- package/dist/lib/html-parser/html-inline.js +181 -0
- package/dist/lib/html-parser/html-parser.d.ts +4 -0
- package/dist/lib/html-parser/html-parser.js +180 -0
- package/dist/lib/html-parser/index.d.ts +3 -0
- package/dist/lib/html-parser/index.js +3 -0
- package/dist/lib/index.d.ts +7 -0
- package/dist/lib/index.js +7 -0
- package/dist/lib/markdown-parser/index.d.ts +1 -0
- package/dist/lib/markdown-parser/index.js +1 -0
- package/dist/lib/markdown-parser/markdown-parser.d.ts +4 -0
- package/dist/lib/markdown-parser/markdown-parser.js +260 -0
- package/dist/lib/metadata/index.d.ts +1 -0
- package/dist/lib/metadata/index.js +1 -0
- package/dist/lib/metadata/metadata.d.ts +42 -0
- package/dist/lib/metadata/metadata.js +85 -0
- package/dist/lib/metadata/text.d.ts +3 -0
- package/dist/lib/metadata/text.js +86 -0
- package/dist/lib/parser/index.d.ts +1 -0
- package/dist/lib/parser/index.js +1 -0
- package/dist/lib/parser/parser.d.ts +2 -0
- package/dist/lib/parser/parser.js +319 -0
- package/dist/lib/relations/index.d.ts +1 -0
- package/dist/lib/relations/index.js +1 -0
- package/dist/lib/relations/relations.d.ts +43 -0
- package/dist/lib/relations/relations.js +139 -0
- package/dist/lib/sanitize/index.d.ts +1 -0
- package/dist/lib/sanitize/index.js +1 -0
- package/dist/lib/sanitize/sanitize.d.ts +27 -0
- package/dist/lib/sanitize/sanitize.js +207 -0
- package/dist/lib/theme/index.d.ts +1 -0
- package/dist/lib/theme/index.js +1 -0
- package/dist/lib/theme/theme.d.ts +5 -0
- package/dist/lib/theme/theme.js +181 -0
- package/dist/types/common.d.ts +3 -0
- package/dist/types/common.js +1 -0
- package/dist/types/custom.d.ts +6 -0
- package/dist/types/custom.js +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.js +4 -0
- package/dist/types/theme.d.ts +137 -0
- package/dist/types/theme.js +1 -0
- package/dist/types/tiptap-inline.d.ts +94 -0
- package/dist/types/tiptap-inline.js +1 -0
- package/dist/types/tiptap.d.ts +249 -0
- package/dist/types/tiptap.js +1 -0
- package/package.json +162 -4
- package/src/components/TiptapBlocks/TiptapBlocks.astro +126 -0
- package/src/components/blocks/TiptapBlockCallout/TiptapBlockCallout.astro +135 -0
- package/src/components/blocks/TiptapBlockCode/TiptapBlockCode.astro +155 -0
- package/src/components/blocks/TiptapBlockColumns/TiptapBlockColumns.astro +99 -0
- package/src/components/blocks/TiptapBlockCustom/TiptapBlockCustom.astro +34 -0
- package/src/components/blocks/TiptapBlockDetails/TiptapBlockDetails.astro +94 -0
- package/src/components/blocks/TiptapBlockHeading/TiptapBlockHeading.astro +118 -0
- package/src/components/blocks/TiptapBlockHr/TiptapBlockHr.astro +41 -0
- package/src/components/blocks/TiptapBlockImage/TiptapBlockImage.astro +120 -0
- package/src/components/blocks/TiptapBlockList/TiptapBlockList.astro +179 -0
- package/src/components/blocks/TiptapBlockMath/TiptapBlockMath.astro +64 -0
- package/src/components/blocks/TiptapBlockMedia/TiptapBlockMedia.astro +194 -0
- package/src/components/blocks/TiptapBlockParagraph/TiptapBlockParagraph.astro +92 -0
- package/src/components/blocks/TiptapBlockQuote/TiptapBlockQuote.astro +72 -0
- package/src/components/blocks/TiptapBlockTable/TiptapBlockTable.astro +171 -0
- package/src/components/blocks/TiptapBlockToc/TiptapBlockToc.astro +108 -0
- package/src/components/blocks/items/TiptapInlineNodes/TiptapInlineNodes.astro +101 -0
- package/src/components/blocks/items/TiptapLinkItem/TiptapLinkItem.astro +97 -0
- package/src/components/blocks/items/TiptapNestedBlocks/TiptapNestedBlocks.astro +195 -0
- package/src/components/blocks/items/TiptapTextItem/TiptapTextItem.astro +148 -0
- package/src/components/index.d.ts +22 -0
- package/src/components/index.ts +43 -0
- package/src/fixtures/all-features-payload.json +865 -0
- package/src/index.d.ts +30 -0
- package/src/index.ts +7 -0
- package/src/lib/html-parser/html-custom-blocks.ts +194 -0
- package/src/lib/html-parser/html-inline.ts +159 -0
- package/src/lib/html-parser/html-parser.ts +179 -0
- package/src/lib/html-parser/html-tokenizer.ts +110 -0
- package/src/lib/html-parser/index.ts +3 -0
- package/src/lib/index.ts +7 -0
- package/src/lib/markdown-parser/index.ts +1 -0
- package/src/lib/markdown-parser/markdown-inline.ts +144 -0
- package/src/lib/markdown-parser/markdown-parser.ts +163 -0
- package/src/lib/metadata/index.ts +1 -0
- package/src/lib/metadata/metadata.ts +123 -0
- package/src/lib/metadata/text.ts +85 -0
- package/src/lib/parser/index.ts +2 -0
- package/src/lib/parser/parser-inline.ts +150 -0
- package/src/lib/parser/parser-sanitize.ts +163 -0
- package/src/lib/parser/parser.ts +117 -0
- package/src/lib/relations/index.ts +1 -0
- package/src/lib/relations/relations-uri.ts +58 -0
- package/src/lib/relations/relations.ts +153 -0
- package/src/lib/sanitize/index.ts +1 -0
- package/src/lib/sanitize/sanitize-format.ts +161 -0
- package/src/lib/sanitize/sanitize.ts +150 -0
- package/src/lib/theme/index.ts +1 -0
- package/src/lib/theme/theme-default.ts +124 -0
- package/src/lib/theme/theme.ts +101 -0
- package/src/types/common.ts +5 -0
- package/src/types/custom.ts +28 -0
- package/src/types/index.ts +4 -0
- package/src/types/theme.ts +167 -0
- package/src/types/tiptap-inline.ts +110 -0
- package/src/types/tiptap-media.ts +153 -0
- package/src/types/tiptap.ts +169 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vicacha Devs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,168 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@vicacha-devs/astro-tiptap-blocks`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Render **Tiptap / ProseMirror** rich text content across **JSON**, **HTML**, and **Markdown** output modes using native, themeable **Astro** block components with zero `set:html` injection.
|
|
4
|
+
|
|
5
|
+
> **Astro compatibility:** `^5.0.0 || ^6.0.0 || ^7.0.0`
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Zero `set:html` / `innerHTML` Rendering** — Every block and inline mark is parsed into a typed `TiptapDocNode` AST and rendered through native Astro components (`{text}`).
|
|
12
|
+
- **Rich Tiptap & ProseMirror Extension Support**:
|
|
13
|
+
- **Typography & Inline Marks**: `bold`, `italic`, `underline`, `strike`, `code`, `subscript` (`<sub>`), `superscript` (`<sup>`), `highlight` (with custom color), `link` (with `tiptap://` / `internal://` link resolution), `textStyle` (`color`, `fontFamily`, `fontSize`, `lineHeight`), `abbr` (`<abbr title>`), `language` (`<span lang dir>`), inline `linker` chips, and inline `mathematics` formulas.
|
|
14
|
+
- **Headings & Lead Paragraphs**: `heading` (`h1`–`h6` with automatic slug `id` generation, optional `#` permalink anchors via `headingAnchors`, and SEO `tag` override) and `leadParagraph` (`data-lead="true"`).
|
|
15
|
+
- **Callouts & Accordions**: `informationPane` (`info`, `success`, `warning`, `danger`, `neutral`, `tip`), `conversationHistory` (author & timestamp), and collapsible `details` (`detailsSummary` + `detailsContent`).
|
|
16
|
+
- **Multi-Column Layouts**: `layoutContainer` and `layoutColumn` with configurable column ratios (`1:1`, `1:1:1`, `2:1`, `1:2`, `1:1:1:1`).
|
|
17
|
+
- **Lists & Checklists**: `bulletList`, `orderedList`, and `taskList` (`taskItem` with interactive-styled read-only checkboxes).
|
|
18
|
+
- **Code Blocks with Line Numbers & Copy Toolbar**: `codeBlock` with Shiki syntax highlighting, `showCodeLineNumbers` (`data-line-number`), and `copyableCodeBlocks` (`data-copy-code`).
|
|
19
|
+
- **Media, Responsive Images & Sandboxed Embeds**: `image` (with `mediaBaseUrl`, `srcset`, `sizes`, `loading`, `decoding`, `fetchpriority`, `caption`, `data-align`, `data-asset-id`, `data-document-id`), `video` (YouTube/Vimeo embeds & `<video controls>`), `audio` (`<audio controls>`), `attachment` (downloadable file card with formatted size & badge), and HTTPS-only sandboxed `iframe` (with optional `allowedIframeDomains` allowlist).
|
|
20
|
+
- **Mathematics, TOC & Page Breaks**: Display/inline `mathematics` (`role="math"` with Unicode formula preview), auto-populated `tableOfContents`, and `pageBreak`.
|
|
21
|
+
- **Advanced Tables**: `table`, `tableRow`, `tableHeader`, and `tableCell` supporting `colspan`, `rowspan`, `colwidth`, and sanitized `backgroundColor`.
|
|
22
|
+
- **3-Format Input Normalization** — Seamlessly accepts ProseMirror JSON (`TiptapDocNode` or JSON string), Tiptap HTML strings, Tiptap Markdown strings (including lossless `<!-- tiptap-node:{...} -->` metadata comments), or `string[]` fallback arrays.
|
|
23
|
+
- **Built-in SEO Metadata, Reading Time & Document Relation Extraction** — Includes `extractDocumentMetadata()`, `extractPlainTextFromTiptap()`, `calculateReadingTime()`, `extractHeadingsFromTiptap()`, and `extractRelationsFromTiptap()`.
|
|
24
|
+
- **Granular Subpath Exports** — Import the Astro component from `@vicacha-devs/astro-tiptap-blocks`, built-in block components from `@vicacha-devs/astro-tiptap-blocks/components`, pure TypeScript utilities from `@vicacha-devs/astro-tiptap-blocks/lib`, or types from `@vicacha-devs/astro-tiptap-blocks/types`.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm add @vicacha-devs/astro-tiptap-blocks
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Tailwind CSS v4 (`@tailwindcss/vite`)
|
|
35
|
+
|
|
36
|
+
```css
|
|
37
|
+
/* src/styles/global.css */
|
|
38
|
+
@import "tailwindcss";
|
|
39
|
+
@source "../../node_modules/@vicacha-devs/astro-tiptap-blocks/src/**/*.{astro,ts}";
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```astro
|
|
47
|
+
---
|
|
48
|
+
import { TiptapBlocks, extractDocumentMetadata } from "@vicacha-devs/astro-tiptap-blocks";
|
|
49
|
+
|
|
50
|
+
const articleContent = {
|
|
51
|
+
type: "doc",
|
|
52
|
+
content: [
|
|
53
|
+
{ type: "tableOfContents" },
|
|
54
|
+
{
|
|
55
|
+
type: "leadParagraph",
|
|
56
|
+
content: [{ type: "text", text: "Executive summary of our new release." }],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "heading",
|
|
60
|
+
attrs: { level: 2, tag: "h2" },
|
|
61
|
+
content: [{ type: "text", text: "Key Highlights" }],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
type: "informationPane",
|
|
65
|
+
attrs: { variant: "tip", title: "Pro Tip" },
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: "paragraph",
|
|
69
|
+
content: [{ type: "text", text: "Works with JSON, HTML, and Markdown!" }],
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const { excerpt, readingTime, headings } = extractDocumentMetadata(articleContent);
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
<p class="text-sm text-slate-500">{readingTime.text}</p>
|
|
80
|
+
|
|
81
|
+
<TiptapBlocks
|
|
82
|
+
data={articleContent}
|
|
83
|
+
mediaBaseUrl="https://cdn.example.com"
|
|
84
|
+
headingAnchors
|
|
85
|
+
showCodeLineNumbers
|
|
86
|
+
copyableCodeBlocks
|
|
87
|
+
allowedIframeDomains={["youtube.com", "player.vimeo.com", "openstreetmap.org"]}
|
|
88
|
+
resolveInternalLink={({ collectionName, documentId }) =>
|
|
89
|
+
`/blog/${collectionName.split(".").pop()}/${documentId}`
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Programmatic Utilities (`@vicacha-devs/astro-tiptap-blocks/lib`)
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import {
|
|
100
|
+
parseTiptapContent,
|
|
101
|
+
parseTiptapHtml,
|
|
102
|
+
parseTiptapMarkdown,
|
|
103
|
+
extractDocumentMetadata,
|
|
104
|
+
extractPlainTextFromTiptap,
|
|
105
|
+
calculateReadingTime,
|
|
106
|
+
extractHeadingsFromTiptap,
|
|
107
|
+
extractRelationsFromTiptap,
|
|
108
|
+
parseInternalLinkUri,
|
|
109
|
+
formatInternalLinkUri,
|
|
110
|
+
resolveInternalLinkHref,
|
|
111
|
+
resolveTiptapMediaUrl,
|
|
112
|
+
sanitizeTiptapUrl,
|
|
113
|
+
sanitizeTiptapIframeUrl,
|
|
114
|
+
sanitizeCssColor,
|
|
115
|
+
buildSafeInlineStyle,
|
|
116
|
+
buildThemeObject,
|
|
117
|
+
TiptapBlockThemeDefault,
|
|
118
|
+
} from "@vicacha-devs/astro-tiptap-blocks/lib";
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Customizing Theme & Extending with Custom Components
|
|
122
|
+
|
|
123
|
+
- **Theme & CSS (`theme.extend` / `theme.overwrite`)**: Customize Tailwind classes, replace slots with BEM/vanilla CSS, or style via `data-*` selectors. See **Guides / Customizing Theme & Styling** (`src/lib/theme/CustomizingTheme.docs.mdx`).
|
|
124
|
+
- **Custom Block Components (`blocks` prop)**: Override any built-in block renderer (`TiptapBlockUserComponents`) or render custom Tiptap extensions (`TiptapCustomBlockNode` in JSON/Markdown or `data-tiptap-type="<nodeName>"` in HTML) by passing the custom node `type` key in `blocks`. See **Guides / Extending & Custom Components** (`src/components/blocks/TiptapBlockCustom/ExtendingCustomComponents.docs.mdx`):
|
|
125
|
+
|
|
126
|
+
```astro
|
|
127
|
+
---
|
|
128
|
+
import { TiptapBlocks } from "@vicacha-devs/astro-tiptap-blocks";
|
|
129
|
+
import BeforeAfterSlider from "./BeforeAfterSlider.astro";
|
|
130
|
+
import CustomCallout from "./CustomCallout.astro";
|
|
131
|
+
import CustomHeading from "./CustomHeading.astro";
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
<TiptapBlocks
|
|
135
|
+
data={articleContent}
|
|
136
|
+
blocks={{
|
|
137
|
+
heading: CustomHeading,
|
|
138
|
+
informationPane: CustomCallout,
|
|
139
|
+
beforeAfterSlider: BeforeAfterSlider,
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Interactive Documentation & Testing
|
|
147
|
+
|
|
148
|
+
This repository includes a full TypeScript **Storybook for Astro** (`@storybook-astro/framework` + `@storybook/addon-docs` + `@storybook/addon-a11y` + themes, designs, RTL and tag badges) suite and **Vitest** unit/component tests for every `.astro` component and `src/lib/` module.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
# Start Storybook dev server on http://localhost:6006
|
|
152
|
+
pnpm storybook
|
|
153
|
+
|
|
154
|
+
# Run Vitest unit & component tests with coverage
|
|
155
|
+
pnpm test:coverage
|
|
156
|
+
|
|
157
|
+
# Run every story as a browser test (first run: pnpm exec playwright install chromium)
|
|
158
|
+
pnpm test:stories
|
|
159
|
+
|
|
160
|
+
# Typecheck & build declaration files
|
|
161
|
+
pnpm check && pnpm build
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT © Vicacha Devs
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TiptapBlockNode } from "../../types";
|
|
2
|
+
import { type SimpleElement } from "./html-inline";
|
|
3
|
+
export declare const findChildByTag: (el: SimpleElement, tag: string) => SimpleElement | undefined;
|
|
4
|
+
export declare const convertCustomHtmlElement: (el: SimpleElement, convertTree: (nodes: Array<SimpleElement | string>) => TiptapBlockNode[]) => TiptapBlockNode[] | null;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { sanitizeTiptapIframeUrl, sanitizeTiptapUrl } from "../sanitize";
|
|
2
|
+
import { collectInlineNodes, extractRawText } from "./html-inline";
|
|
3
|
+
export const findChildByTag = (el, tag) => el.children.find((c) => typeof c !== "string" && c.tag === tag);
|
|
4
|
+
export const convertCustomHtmlElement = (el, convertTree) => {
|
|
5
|
+
const dataType = el.attrs["data-type"] || "";
|
|
6
|
+
if (dataType === "page-break" || (el.tag === "hr" && el.attrs["data-page-break"])) {
|
|
7
|
+
return [{ type: "pageBreak" }];
|
|
8
|
+
}
|
|
9
|
+
if (dataType === "table-of-contents") {
|
|
10
|
+
return [{ type: "tableOfContents", attrs: { title: el.attrs["data-title"] || null } }];
|
|
11
|
+
}
|
|
12
|
+
if (dataType === "mathematics") {
|
|
13
|
+
const latex = el.attrs["data-latex"] || extractRawText(el.children);
|
|
14
|
+
return [{ type: "mathematics", attrs: { latex, displayMode: "block" } }];
|
|
15
|
+
}
|
|
16
|
+
if (dataType === "information-pane") {
|
|
17
|
+
const variant = (el.attrs["data-variant"] || "info");
|
|
18
|
+
const titleEl = el.children.find((c) => typeof c !== "string" && (c.attrs["data-type"] === "pane-title" || c.tag === "strong"));
|
|
19
|
+
const title = el.attrs["data-title"] || (titleEl ? extractRawText(titleEl.children) : null);
|
|
20
|
+
const rest = titleEl ? el.children.filter((c) => c !== titleEl) : el.children;
|
|
21
|
+
return [{ type: "informationPane", attrs: { variant, title }, content: convertTree(rest) }];
|
|
22
|
+
}
|
|
23
|
+
if (dataType === "conversation-history") {
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
type: "conversationHistory",
|
|
27
|
+
attrs: {
|
|
28
|
+
author: el.attrs["data-author"] || null,
|
|
29
|
+
timestamp: el.attrs["data-timestamp"] || null,
|
|
30
|
+
},
|
|
31
|
+
content: convertTree(el.children),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
if (dataType === "layout-container") {
|
|
36
|
+
const columns = el.children
|
|
37
|
+
.filter((c) => typeof c !== "string" && (c.attrs["data-type"] === "layout-column" || c.tag === "div"))
|
|
38
|
+
.map((col) => ({ type: "layoutColumn", content: convertTree(col.children) }));
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
type: "layoutContainer",
|
|
42
|
+
attrs: { columns: el.attrs["data-columns"] || "1:1" },
|
|
43
|
+
content: columns,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
if (dataType === "attachment") {
|
|
48
|
+
const link = findChildByTag(el, "a") || el;
|
|
49
|
+
const href = sanitizeTiptapUrl(link.attrs.href || el.attrs["data-href"]);
|
|
50
|
+
if (href === "#")
|
|
51
|
+
return [];
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
type: "attachment",
|
|
55
|
+
attrs: {
|
|
56
|
+
href,
|
|
57
|
+
"filename": el.attrs["data-filename"] ||
|
|
58
|
+
link.attrs.download ||
|
|
59
|
+
extractRawText(link.children) ||
|
|
60
|
+
"Attachment",
|
|
61
|
+
"title": el.attrs["data-title"] || link.attrs.title || null,
|
|
62
|
+
"size": el.attrs["data-size"] || null,
|
|
63
|
+
"mime": el.attrs["data-mime"] || null,
|
|
64
|
+
"ext": el.attrs["data-ext"] || null,
|
|
65
|
+
"data-asset-id": el.attrs["data-asset-id"] || null,
|
|
66
|
+
"data-document-id": el.attrs["data-document-id"] || null,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
if (el.tag === "iframe" || dataType === "iframe-wrapper") {
|
|
72
|
+
const iframeEl = el.tag === "iframe" ? el : findChildByTag(el, "iframe");
|
|
73
|
+
const safeSrc = sanitizeTiptapIframeUrl(iframeEl?.attrs.src || el.attrs["data-src"]);
|
|
74
|
+
if (safeSrc === "#")
|
|
75
|
+
return [];
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
type: "iframe",
|
|
79
|
+
attrs: {
|
|
80
|
+
"src": safeSrc,
|
|
81
|
+
"title": iframeEl?.attrs.title || el.attrs.title || null,
|
|
82
|
+
"width": iframeEl?.attrs.width || el.attrs.width || "100%",
|
|
83
|
+
"height": iframeEl?.attrs.height || el.attrs.height || 400,
|
|
84
|
+
"data-align": el.attrs["data-align"] || null,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
if (el.tag === "video" || dataType === "video") {
|
|
90
|
+
const videoEl = el.tag === "video" ? el : findChildByTag(el, "video") || findChildByTag(el, "iframe");
|
|
91
|
+
const src = sanitizeTiptapUrl(videoEl?.attrs.src || el.attrs["data-src"]);
|
|
92
|
+
if (src === "#")
|
|
93
|
+
return [];
|
|
94
|
+
const embedType = videoEl?.tag === "iframe" || el.attrs["data-embed-type"] === "embed" ? "embed" : "file";
|
|
95
|
+
const capEl = findChildByTag(el, "figcaption");
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
type: "video",
|
|
99
|
+
attrs: {
|
|
100
|
+
src,
|
|
101
|
+
embedType,
|
|
102
|
+
"title": videoEl?.attrs.title || el.attrs.title || null,
|
|
103
|
+
"caption": el.attrs["data-caption"] || (capEl ? extractRawText(capEl.children) : null),
|
|
104
|
+
"data-align": el.attrs["data-align"] || null,
|
|
105
|
+
"data-asset-id": el.attrs["data-asset-id"] || null,
|
|
106
|
+
"data-document-id": el.attrs["data-document-id"] || null,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
if (el.tag === "audio" || dataType === "audio") {
|
|
112
|
+
const audioEl = el.tag === "audio" ? el : findChildByTag(el, "audio");
|
|
113
|
+
const src = sanitizeTiptapUrl(audioEl?.attrs.src || el.attrs["data-src"]);
|
|
114
|
+
if (src === "#")
|
|
115
|
+
return [];
|
|
116
|
+
const capEl = findChildByTag(el, "figcaption");
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
type: "audio",
|
|
120
|
+
attrs: {
|
|
121
|
+
src,
|
|
122
|
+
"title": audioEl?.attrs.title || el.attrs.title || null,
|
|
123
|
+
"caption": el.attrs["data-caption"] || (capEl ? extractRawText(capEl.children) : null),
|
|
124
|
+
"data-align": el.attrs["data-align"] || null,
|
|
125
|
+
"data-asset-id": el.attrs["data-asset-id"] || null,
|
|
126
|
+
"data-document-id": el.attrs["data-document-id"] || null,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
if (el.tag === "details") {
|
|
132
|
+
const summaryEl = findChildByTag(el, "summary");
|
|
133
|
+
const rest = el.children.filter((c) => c !== summaryEl);
|
|
134
|
+
return [
|
|
135
|
+
{
|
|
136
|
+
type: "details",
|
|
137
|
+
attrs: { open: "open" in el.attrs && el.attrs.open !== "false" },
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: "detailsSummary",
|
|
141
|
+
content: summaryEl ? collectInlineNodes(summaryEl.children) : [],
|
|
142
|
+
},
|
|
143
|
+
{ type: "detailsContent", content: convertTree(rest) },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
const customNodeType = el.attrs["data-tiptap-type"];
|
|
149
|
+
if (customNodeType) {
|
|
150
|
+
const attrs = {};
|
|
151
|
+
for (const [k, v] of Object.entries(el.attrs)) {
|
|
152
|
+
if (k === "data-tiptap-type" || k === "class")
|
|
153
|
+
continue;
|
|
154
|
+
const propKey = k
|
|
155
|
+
.replace(/^data-/, "")
|
|
156
|
+
.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
157
|
+
attrs[propKey] = v;
|
|
158
|
+
}
|
|
159
|
+
return [{ type: customNodeType, attrs }];
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { TiptapInlineNode, TiptapMark } from "../../types";
|
|
2
|
+
export interface HtmlToken {
|
|
3
|
+
type: "open" | "close" | "self" | "text";
|
|
4
|
+
tag?: string;
|
|
5
|
+
attrs?: Record<string, string>;
|
|
6
|
+
text?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SimpleElement {
|
|
9
|
+
tag: string;
|
|
10
|
+
attrs: Record<string, string>;
|
|
11
|
+
children: Array<SimpleElement | string>;
|
|
12
|
+
}
|
|
13
|
+
export declare const tokenizeHtml: (html: string) => HtmlToken[];
|
|
14
|
+
export declare const buildElementTree: (tokens: HtmlToken[]) => Array<SimpleElement | string>;
|
|
15
|
+
export declare const extractRawText: (nodes: Array<SimpleElement | string>) => string;
|
|
16
|
+
export declare const parseInlineStyleProps: (style?: string) => Record<string, string>;
|
|
17
|
+
export declare const collectInlineNodes: (nodes: Array<SimpleElement | string>, activeMarks?: TiptapMark[]) => TiptapInlineNode[];
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { decodeHtmlEntities, sanitizeCssColor, sanitizeCssDimension, sanitizeCssFontFamily, sanitizeTiptapUrl, } from "../sanitize";
|
|
2
|
+
const SELF_CLOSING_TAGS = new Set(["br", "hr", "img", "input", "meta", "link", "source"]);
|
|
3
|
+
const IGNORED_CONTAINERS = new Set(["script", "style", "object", "embed"]);
|
|
4
|
+
const parseAttributes = (attrString) => {
|
|
5
|
+
const attrs = {};
|
|
6
|
+
const attrRegex = /([a-zA-Z0-9_:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
7
|
+
let match;
|
|
8
|
+
while ((match = attrRegex.exec(attrString)) !== null) {
|
|
9
|
+
const key = match[1].toLowerCase();
|
|
10
|
+
const val = match[2] ?? match[3] ?? match[4] ?? "";
|
|
11
|
+
attrs[key] = decodeHtmlEntities(val);
|
|
12
|
+
}
|
|
13
|
+
return attrs;
|
|
14
|
+
};
|
|
15
|
+
export const tokenizeHtml = (html) => {
|
|
16
|
+
const sanitized = html
|
|
17
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
18
|
+
.replace(/<(script|style|object|embed)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
|
|
19
|
+
const tokens = [];
|
|
20
|
+
const tagRegex = /<\/?([a-zA-Z0-9-]+)([^>]*)>/g;
|
|
21
|
+
let lastIndex = 0;
|
|
22
|
+
let match;
|
|
23
|
+
while ((match = tagRegex.exec(sanitized)) !== null) {
|
|
24
|
+
if (match.index > lastIndex) {
|
|
25
|
+
tokens.push({ type: "text", text: sanitized.slice(lastIndex, match.index) });
|
|
26
|
+
}
|
|
27
|
+
const fullMatch = match[0];
|
|
28
|
+
const tag = match[1].toLowerCase();
|
|
29
|
+
const rawAttrs = match[2] || "";
|
|
30
|
+
const isClosing = fullMatch.startsWith("</");
|
|
31
|
+
const isSelfClosing = fullMatch.endsWith("/>") || SELF_CLOSING_TAGS.has(tag);
|
|
32
|
+
if (!IGNORED_CONTAINERS.has(tag)) {
|
|
33
|
+
if (isClosing)
|
|
34
|
+
tokens.push({ type: "close", tag });
|
|
35
|
+
else if (isSelfClosing)
|
|
36
|
+
tokens.push({ type: "self", tag, attrs: parseAttributes(rawAttrs) });
|
|
37
|
+
else
|
|
38
|
+
tokens.push({ type: "open", tag, attrs: parseAttributes(rawAttrs) });
|
|
39
|
+
}
|
|
40
|
+
lastIndex = tagRegex.lastIndex;
|
|
41
|
+
}
|
|
42
|
+
if (lastIndex < sanitized.length) {
|
|
43
|
+
tokens.push({ type: "text", text: sanitized.slice(lastIndex) });
|
|
44
|
+
}
|
|
45
|
+
return tokens;
|
|
46
|
+
};
|
|
47
|
+
export const buildElementTree = (tokens) => {
|
|
48
|
+
const root = { tag: "root", attrs: {}, children: [] };
|
|
49
|
+
const stack = [root];
|
|
50
|
+
for (const token of tokens) {
|
|
51
|
+
const current = stack[stack.length - 1];
|
|
52
|
+
if (token.type === "text" && token.text !== undefined) {
|
|
53
|
+
current.children.push(token.text);
|
|
54
|
+
}
|
|
55
|
+
else if (token.type === "self" && token.tag) {
|
|
56
|
+
current.children.push({ tag: token.tag, attrs: token.attrs || {}, children: [] });
|
|
57
|
+
}
|
|
58
|
+
else if (token.type === "open" && token.tag) {
|
|
59
|
+
const el = { tag: token.tag, attrs: token.attrs || {}, children: [] };
|
|
60
|
+
current.children.push(el);
|
|
61
|
+
stack.push(el);
|
|
62
|
+
}
|
|
63
|
+
else if (token.type === "close" && token.tag) {
|
|
64
|
+
for (let i = stack.length - 1; i >= 1; i--) {
|
|
65
|
+
if (stack[i].tag === token.tag) {
|
|
66
|
+
stack.length = i;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return root.children;
|
|
73
|
+
};
|
|
74
|
+
export const extractRawText = (nodes) => nodes
|
|
75
|
+
.map((n) => typeof n === "string"
|
|
76
|
+
? decodeHtmlEntities(n)
|
|
77
|
+
: n.tag === "br"
|
|
78
|
+
? "\n"
|
|
79
|
+
: extractRawText(n.children))
|
|
80
|
+
.join("");
|
|
81
|
+
export const parseInlineStyleProps = (style) => {
|
|
82
|
+
const map = {};
|
|
83
|
+
if (!style)
|
|
84
|
+
return map;
|
|
85
|
+
for (const part of style.split(";")) {
|
|
86
|
+
const idx = part.indexOf(":");
|
|
87
|
+
if (idx > 0)
|
|
88
|
+
map[part.slice(0, idx).trim().toLowerCase()] = part.slice(idx + 1).trim();
|
|
89
|
+
}
|
|
90
|
+
return map;
|
|
91
|
+
};
|
|
92
|
+
export const collectInlineNodes = (nodes, activeMarks = []) => {
|
|
93
|
+
const result = [];
|
|
94
|
+
for (const node of nodes) {
|
|
95
|
+
if (typeof node === "string") {
|
|
96
|
+
const decoded = decodeHtmlEntities(node);
|
|
97
|
+
if (decoded.length > 0) {
|
|
98
|
+
result.push({
|
|
99
|
+
type: "text",
|
|
100
|
+
text: decoded,
|
|
101
|
+
...(activeMarks.length > 0 ? { marks: [...activeMarks] } : {}),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (node.tag === "br") {
|
|
107
|
+
result.push({ type: "hardBreak" });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (node.attrs["data-type"] === "mathematics") {
|
|
111
|
+
const latex = node.attrs["data-latex"] || extractRawText(node.children);
|
|
112
|
+
result.push({ type: "mathematics", attrs: { latex, displayMode: "inline" } });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (node.tag === "a" &&
|
|
116
|
+
(node.attrs["data-type"] === "linker" || node.attrs["data-collection"])) {
|
|
117
|
+
result.push({
|
|
118
|
+
type: "linker",
|
|
119
|
+
attrs: {
|
|
120
|
+
collectionName: node.attrs["data-collection"] || null,
|
|
121
|
+
documentId: node.attrs["data-document-id"] || null,
|
|
122
|
+
label: node.attrs["data-label"] || extractRawText(node.children) || null,
|
|
123
|
+
href: sanitizeTiptapUrl(node.attrs.href),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const nextMarks = [...activeMarks];
|
|
129
|
+
const styles = parseInlineStyleProps(node.attrs.style);
|
|
130
|
+
if (node.tag === "strong" || node.tag === "b")
|
|
131
|
+
nextMarks.push({ type: "bold" });
|
|
132
|
+
else if (node.tag === "em" || node.tag === "i")
|
|
133
|
+
nextMarks.push({ type: "italic" });
|
|
134
|
+
else if (node.tag === "u")
|
|
135
|
+
nextMarks.push({ type: "underline" });
|
|
136
|
+
else if (node.tag === "s" || node.tag === "strike" || node.tag === "del")
|
|
137
|
+
nextMarks.push({ type: "strike" });
|
|
138
|
+
else if (node.tag === "code")
|
|
139
|
+
nextMarks.push({ type: "code" });
|
|
140
|
+
else if (node.tag === "sub")
|
|
141
|
+
nextMarks.push({ type: "subscript" });
|
|
142
|
+
else if (node.tag === "sup")
|
|
143
|
+
nextMarks.push({ type: "superscript" });
|
|
144
|
+
else if (node.tag === "abbr")
|
|
145
|
+
nextMarks.push({ type: "abbr", attrs: { title: node.attrs.title || null } });
|
|
146
|
+
else if (node.tag === "mark") {
|
|
147
|
+
const color = sanitizeCssColor(node.attrs["data-color"] || styles["background-color"]);
|
|
148
|
+
nextMarks.push({ type: "highlight", ...(color ? { attrs: { color } } : {}) });
|
|
149
|
+
}
|
|
150
|
+
else if (node.tag === "a") {
|
|
151
|
+
nextMarks.push({
|
|
152
|
+
type: "link",
|
|
153
|
+
attrs: {
|
|
154
|
+
href: sanitizeTiptapUrl(node.attrs.href),
|
|
155
|
+
target: node.attrs.target || null,
|
|
156
|
+
rel: node.attrs.rel || null,
|
|
157
|
+
title: node.attrs.title || null,
|
|
158
|
+
class: node.attrs.class || null,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (node.attrs.lang || node.attrs.dir) {
|
|
163
|
+
nextMarks.push({
|
|
164
|
+
type: "language",
|
|
165
|
+
attrs: {
|
|
166
|
+
lang: node.attrs.lang || null,
|
|
167
|
+
dir: node.attrs.dir || null,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const color = sanitizeCssColor(styles.color);
|
|
172
|
+
const fontFamily = sanitizeCssFontFamily(styles["font-family"]);
|
|
173
|
+
const fontSize = sanitizeCssDimension(styles["font-size"]);
|
|
174
|
+
const lineHeight = sanitizeCssDimension(styles["line-height"]);
|
|
175
|
+
if (color || fontFamily || fontSize || lineHeight) {
|
|
176
|
+
nextMarks.push({ type: "textStyle", attrs: { color, fontFamily, fontSize, lineHeight } });
|
|
177
|
+
}
|
|
178
|
+
result.push(...collectInlineNodes(node.children, nextMarks));
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TiptapBlockNode, TiptapDocNode } from "../../types";
|
|
2
|
+
import { type SimpleElement } from "./html-inline";
|
|
3
|
+
export declare const convertTreeToBlocks: (nodes: Array<SimpleElement | string>) => TiptapBlockNode[];
|
|
4
|
+
export declare const parseTiptapHtml: (html: string) => TiptapDocNode;
|