@teriusu/rich-editor 0.4.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 +178 -0
- package/dist/components/BubbleToolbar.svelte +299 -0
- package/dist/components/BubbleToolbar.svelte.d.ts +9 -0
- package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -0
- package/dist/components/FixedToolbar.svelte +892 -0
- package/dist/components/FixedToolbar.svelte.d.ts +11 -0
- package/dist/components/FixedToolbar.svelte.d.ts.map +1 -0
- package/dist/components/InputModal.svelte +102 -0
- package/dist/components/InputModal.svelte.d.ts +11 -0
- package/dist/components/InputModal.svelte.d.ts.map +1 -0
- package/dist/components/PdfViewer.svelte +152 -0
- package/dist/components/PdfViewer.svelte.d.ts +8 -0
- package/dist/components/PdfViewer.svelte.d.ts.map +1 -0
- package/dist/components/SlashCommandMenu.svelte +315 -0
- package/dist/components/SlashCommandMenu.svelte.d.ts +14 -0
- package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -0
- package/dist/components/TableBubbleMenu.svelte +472 -0
- package/dist/components/TableBubbleMenu.svelte.d.ts +8 -0
- package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -0
- package/dist/components/TipTapEditor.svelte +649 -0
- package/dist/components/TipTapEditor.svelte.d.ts +17 -0
- package/dist/components/TipTapEditor.svelte.d.ts.map +1 -0
- package/dist/extensions/Column.d.ts +3 -0
- package/dist/extensions/Column.d.ts.map +1 -0
- package/dist/extensions/Column.js +20 -0
- package/dist/extensions/Columns.d.ts +11 -0
- package/dist/extensions/Columns.d.ts.map +1 -0
- package/dist/extensions/Columns.js +103 -0
- package/dist/extensions/Columns.test.d.ts +2 -0
- package/dist/extensions/Columns.test.d.ts.map +1 -0
- package/dist/extensions/Columns.test.js +62 -0
- package/dist/extensions/FileAttachment.d.ts +24 -0
- package/dist/extensions/FileAttachment.d.ts.map +1 -0
- package/dist/extensions/FileAttachment.js +190 -0
- package/dist/extensions/FileAttachment.test.d.ts +2 -0
- package/dist/extensions/FileAttachment.test.d.ts.map +1 -0
- package/dist/extensions/FileAttachment.test.js +63 -0
- package/dist/extensions/FixedDetails.d.ts +2 -0
- package/dist/extensions/FixedDetails.d.ts.map +1 -0
- package/dist/extensions/FixedDetails.js +100 -0
- package/dist/extensions/FixedDetails.test.d.ts +2 -0
- package/dist/extensions/FixedDetails.test.d.ts.map +1 -0
- package/dist/extensions/FixedDetails.test.js +40 -0
- package/dist/extensions/Indent.d.ts +11 -0
- package/dist/extensions/Indent.d.ts.map +1 -0
- package/dist/extensions/Indent.js +87 -0
- package/dist/extensions/Indent.test.d.ts +2 -0
- package/dist/extensions/Indent.test.d.ts.map +1 -0
- package/dist/extensions/Indent.test.js +78 -0
- package/dist/extensions/PdfBlock.d.ts +3 -0
- package/dist/extensions/PdfBlock.d.ts.map +1 -0
- package/dist/extensions/PdfBlock.js +301 -0
- package/dist/extensions/PdfBlock.test.d.ts +2 -0
- package/dist/extensions/PdfBlock.test.d.ts.map +1 -0
- package/dist/extensions/PdfBlock.test.js +51 -0
- package/dist/extensions/VideoBlock.d.ts +16 -0
- package/dist/extensions/VideoBlock.d.ts.map +1 -0
- package/dist/extensions/VideoBlock.js +87 -0
- package/dist/extensions/VideoBlock.test.d.ts +2 -0
- package/dist/extensions/VideoBlock.test.d.ts.map +1 -0
- package/dist/extensions/VideoBlock.test.js +54 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/styles/editor.css +698 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils/cn.d.ts +3 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/dist/utils/cn.js +5 -0
- package/dist/utils/cn.test.d.ts +2 -0
- package/dist/utils/cn.test.d.ts.map +1 -0
- package/dist/utils/cn.test.js +28 -0
- package/dist/utils/escape-html.d.ts +2 -0
- package/dist/utils/escape-html.d.ts.map +1 -0
- package/dist/utils/escape-html.js +7 -0
- package/dist/utils/escape-html.test.d.ts +2 -0
- package/dist/utils/escape-html.test.d.ts.map +1 -0
- package/dist/utils/escape-html.test.js +25 -0
- package/dist/utils/pdf.d.ts +9 -0
- package/dist/utils/pdf.d.ts.map +1 -0
- package/dist/utils/pdf.js +33 -0
- package/dist/utils/sanitize.d.ts +9 -0
- package/dist/utils/sanitize.d.ts.map +1 -0
- package/dist/utils/sanitize.js +115 -0
- package/dist/utils/sanitize.test.d.ts +2 -0
- package/dist/utils/sanitize.test.d.ts.map +1 -0
- package/dist/utils/sanitize.test.js +206 -0
- package/package.json +76 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/core";
|
|
2
|
+
import type { Component } from "svelte";
|
|
3
|
+
import type { FileResolver } from "./extensions/FileAttachment";
|
|
4
|
+
/** 파일 업로드 핸들러 -- 호스트 앱에서 구현 */
|
|
5
|
+
export type UploadHandler = (file: File) => Promise<string>;
|
|
6
|
+
/** 툴바 모드 */
|
|
7
|
+
export type ToolbarMode = 'minimal' | 'standard' | 'full';
|
|
8
|
+
export interface TipTapEditorProps {
|
|
9
|
+
content: string;
|
|
10
|
+
onChange: (html: string) => void;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** 이미지/PDF 파일 업로드 시 호출. URL을 반환해야 함 */
|
|
13
|
+
onUploadFile?: UploadHandler;
|
|
14
|
+
/** 파일 ID → URL 변환. 하이브리드 파일 저장 시 사용 */
|
|
15
|
+
onResolveFile?: FileResolver;
|
|
16
|
+
/** 툴바 모드: 'minimal' | 'standard' | 'full' (기본값 'full') */
|
|
17
|
+
toolbar?: ToolbarMode;
|
|
18
|
+
}
|
|
19
|
+
export interface FixedToolbarProps {
|
|
20
|
+
editor: Editor;
|
|
21
|
+
onPdfClick: () => void;
|
|
22
|
+
}
|
|
23
|
+
export interface BlockHandleProps {
|
|
24
|
+
editor: Editor;
|
|
25
|
+
}
|
|
26
|
+
export interface SlashCommandMenuProps {
|
|
27
|
+
editor: Editor;
|
|
28
|
+
query: string;
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
onPdfUpload?: () => void;
|
|
31
|
+
}
|
|
32
|
+
export interface PdfViewerProps {
|
|
33
|
+
src: string;
|
|
34
|
+
fileName?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface SlashMenuItem {
|
|
37
|
+
label: string;
|
|
38
|
+
keywords: string;
|
|
39
|
+
icon: Component<{
|
|
40
|
+
size?: number;
|
|
41
|
+
}>;
|
|
42
|
+
command: (editor: Editor) => void;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAEhE,+BAA+B;AAC/B,MAAM,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE5D,YAAY;AACZ,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,UAAU,GAAG,MAAM,CAAC;AAE1D,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,uCAAuC;IACvC,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B,0DAA0D;IAC1D,OAAO,CAAC,EAAE,WAAW,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,SAAS,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnC,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cn.d.ts","sourceRoot":"","sources":["../../src/utils/cn.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,UAAU,EAAE,MAAM,MAAM,CAAC;AAG7C,wBAAgB,EAAE,CAAC,GAAG,MAAM,EAAE,UAAU,EAAE,UAEzC"}
|
package/dist/utils/cn.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cn.test.d.ts","sourceRoot":"","sources":["../../src/utils/cn.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cn } from './cn';
|
|
3
|
+
describe('cn', () => {
|
|
4
|
+
it('merges class names', () => {
|
|
5
|
+
const result = cn('foo', 'bar');
|
|
6
|
+
expect(result).toBe('foo bar');
|
|
7
|
+
});
|
|
8
|
+
it('handles conditional classes', () => {
|
|
9
|
+
const result = cn('base', false && 'hidden', 'visible');
|
|
10
|
+
expect(result).toBe('base visible');
|
|
11
|
+
});
|
|
12
|
+
it('handles undefined and null', () => {
|
|
13
|
+
const result = cn('base', undefined, null, 'end');
|
|
14
|
+
expect(result).toBe('base end');
|
|
15
|
+
});
|
|
16
|
+
it('merges tailwind classes correctly', () => {
|
|
17
|
+
const result = cn('px-2 py-1', 'px-4');
|
|
18
|
+
expect(result).toBe('py-1 px-4');
|
|
19
|
+
});
|
|
20
|
+
it('handles empty input', () => {
|
|
21
|
+
const result = cn();
|
|
22
|
+
expect(result).toBe('');
|
|
23
|
+
});
|
|
24
|
+
it('handles array input', () => {
|
|
25
|
+
const result = cn(['foo', 'bar']);
|
|
26
|
+
expect(result).toBe('foo bar');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape-html.d.ts","sourceRoot":"","sources":["../../src/utils/escape-html.ts"],"names":[],"mappings":"AAAA,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM9C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape-html.test.d.ts","sourceRoot":"","sources":["../../src/utils/escape-html.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { escapeHtml } from './escape-html';
|
|
3
|
+
describe('escapeHtml', () => {
|
|
4
|
+
it('escapes ampersand', () => {
|
|
5
|
+
expect(escapeHtml('a & b')).toBe('a & b');
|
|
6
|
+
});
|
|
7
|
+
it('escapes less-than', () => {
|
|
8
|
+
expect(escapeHtml('a < b')).toBe('a < b');
|
|
9
|
+
});
|
|
10
|
+
it('escapes greater-than', () => {
|
|
11
|
+
expect(escapeHtml('a > b')).toBe('a > b');
|
|
12
|
+
});
|
|
13
|
+
it('escapes double quotes', () => {
|
|
14
|
+
expect(escapeHtml('a "b" c')).toBe('a "b" c');
|
|
15
|
+
});
|
|
16
|
+
it('escapes all entities together', () => {
|
|
17
|
+
expect(escapeHtml('<div class="x">&</div>')).toBe('<div class="x">&</div>');
|
|
18
|
+
});
|
|
19
|
+
it('handles empty string', () => {
|
|
20
|
+
expect(escapeHtml('')).toBe('');
|
|
21
|
+
});
|
|
22
|
+
it('passes through plain text unchanged', () => {
|
|
23
|
+
expect(escapeHtml('Hello world')).toBe('Hello world');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface PdfJsConfig {
|
|
2
|
+
/** pdf.min.mjs 경로 (기본: "/pdf.min.mjs") */
|
|
3
|
+
pdfSrc?: string;
|
|
4
|
+
/** pdf.worker.min.mjs 경로 (기본: "/pdf.worker.min.mjs") */
|
|
5
|
+
workerSrc?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function configurePdfJs(cfg: PdfJsConfig): void;
|
|
8
|
+
export declare function getPdfJs(): Promise<any>;
|
|
9
|
+
//# sourceMappingURL=pdf.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pdf.d.ts","sourceRoot":"","sources":["../../src/utils/pdf.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,WAAW;IAC1B,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,cAAc,CAAC,GAAG,EAAE,WAAW,QAK9C;AAED,wBAAsB,QAAQ,iBAyB7B"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2
|
+
let cached = null;
|
|
3
|
+
let loading = null;
|
|
4
|
+
let config = {};
|
|
5
|
+
export function configurePdfJs(cfg) {
|
|
6
|
+
config = cfg;
|
|
7
|
+
// 설정 변경 시 캐시 초기화
|
|
8
|
+
cached = null;
|
|
9
|
+
loading = null;
|
|
10
|
+
}
|
|
11
|
+
export async function getPdfJs() {
|
|
12
|
+
if (cached)
|
|
13
|
+
return cached;
|
|
14
|
+
if (!loading) {
|
|
15
|
+
loading = (async () => {
|
|
16
|
+
const pdfSrc = config.pdfSrc || "/pdf.min.mjs";
|
|
17
|
+
const workerSrc = config.workerSrc || "/pdf.worker.min.mjs";
|
|
18
|
+
const res = await fetch(pdfSrc);
|
|
19
|
+
if (!res.ok)
|
|
20
|
+
throw new Error(`${pdfSrc} 로드 실패`);
|
|
21
|
+
const text = await res.text();
|
|
22
|
+
const blob = new Blob([text], { type: "application/javascript" });
|
|
23
|
+
const url = URL.createObjectURL(blob);
|
|
24
|
+
const mod = await import(/* webpackIgnore: true */ url);
|
|
25
|
+
URL.revokeObjectURL(url);
|
|
26
|
+
const pdfjsLib = mod;
|
|
27
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
|
|
28
|
+
cached = pdfjsLib;
|
|
29
|
+
return pdfjsLib;
|
|
30
|
+
})();
|
|
31
|
+
}
|
|
32
|
+
return loading;
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function sanitizeHtml(html: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* 레거시 TipTap v2 커스텀 태그를 현재 형식으로 변환.
|
|
4
|
+
* 에디터 content 로드 전, 또는 게시물 렌더링 전에 호출.
|
|
5
|
+
* tiptap-file → data-file-id, tiptap-midibus → iframe으로 변환.
|
|
6
|
+
*/
|
|
7
|
+
export declare function transformLegacyHtml(html: string): string;
|
|
8
|
+
export declare function stripHtmlToExcerpt(html: string, maxLen?: number): string;
|
|
9
|
+
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../../src/utils/sanitize.ts"],"names":[],"mappings":"AAyBA,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAcjD;AA0BD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAyExD;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,SAAM,GAAG,MAAM,CAWrE"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { escapeHtml } from "./escape-html";
|
|
2
|
+
const ALLOWED_TAGS = new Set([
|
|
3
|
+
"p", "br", "b", "i", "u", "em", "strong", "a",
|
|
4
|
+
"ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
5
|
+
"blockquote", "pre", "code", "img", "figure", "figcaption",
|
|
6
|
+
"table", "colgroup", "col", "thead", "tbody", "tr", "th", "td",
|
|
7
|
+
"span", "div", "hr", "sub", "sup",
|
|
8
|
+
]);
|
|
9
|
+
const ALLOWED_ATTRS = {
|
|
10
|
+
a: new Set(["href", "title", "target", "rel"]),
|
|
11
|
+
img: new Set(["src", "alt", "width", "height"]),
|
|
12
|
+
table: new Set(["style"]),
|
|
13
|
+
col: new Set(["style", "width"]),
|
|
14
|
+
td: new Set(["colspan", "rowspan", "colwidth", "style"]),
|
|
15
|
+
th: new Set(["colspan", "rowspan", "colwidth", "style"]),
|
|
16
|
+
div: new Set(["data-pdf-src", "data-pdf-name"]),
|
|
17
|
+
pre: new Set(["class"]),
|
|
18
|
+
code: new Set(["class"]),
|
|
19
|
+
"*": new Set(["class", "id"]),
|
|
20
|
+
};
|
|
21
|
+
const SAFE_URL_PATTERN = /^(?:https?:\/\/|\/[\w])/i;
|
|
22
|
+
export function sanitizeHtml(html) {
|
|
23
|
+
return String(html || "")
|
|
24
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
25
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
26
|
+
.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (match, tag, attrs) => {
|
|
27
|
+
const lower = tag.toLowerCase();
|
|
28
|
+
if (!ALLOWED_TAGS.has(lower))
|
|
29
|
+
return "";
|
|
30
|
+
const cleanAttrs = sanitizeAttributes(lower, attrs);
|
|
31
|
+
const isClosing = match.startsWith("</");
|
|
32
|
+
if (isClosing)
|
|
33
|
+
return `</${lower}>`;
|
|
34
|
+
const selfClosing = match.endsWith("/>");
|
|
35
|
+
return `<${lower}${cleanAttrs}${selfClosing ? " /" : ""}>`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function sanitizeAttributes(tag, attrString) {
|
|
39
|
+
const allowedForTag = ALLOWED_ATTRS[tag] ?? new Set();
|
|
40
|
+
const allowedGlobal = ALLOWED_ATTRS["*"] ?? new Set();
|
|
41
|
+
const result = [];
|
|
42
|
+
const attrRegex = /([a-zA-Z][\w-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))/g;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = attrRegex.exec(attrString)) !== null) {
|
|
45
|
+
const name = m[1].toLowerCase();
|
|
46
|
+
const value = m[2] ?? m[3] ?? m[4] ?? "";
|
|
47
|
+
if (name.startsWith("on"))
|
|
48
|
+
continue;
|
|
49
|
+
if (!allowedForTag.has(name) && !allowedGlobal.has(name))
|
|
50
|
+
continue;
|
|
51
|
+
if ((name === "href" || name === "src") && !SAFE_URL_PATTERN.test(value)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
result.push(` ${name}="${escapeHtml(value)}"`);
|
|
55
|
+
}
|
|
56
|
+
return result.join("");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 레거시 TipTap v2 커스텀 태그를 현재 형식으로 변환.
|
|
60
|
+
* 에디터 content 로드 전, 또는 게시물 렌더링 전에 호출.
|
|
61
|
+
* tiptap-file → data-file-id, tiptap-midibus → iframe으로 변환.
|
|
62
|
+
*/
|
|
63
|
+
export function transformLegacyHtml(html) {
|
|
64
|
+
if (!html)
|
|
65
|
+
return html;
|
|
66
|
+
return (html
|
|
67
|
+
// <tiptap-collapsable title="X">content</tiptap-collapsable>
|
|
68
|
+
// → <details><summary>X</summary><div>content</div></details>
|
|
69
|
+
.replace(/<tiptap-collapsable\s+title="([^"]*)">([\s\S]*?)<\/tiptap-collapsable>/gi, '<details><summary>$1</summary><div>$2</div></details>')
|
|
70
|
+
// <lite-youtube videoid="X" ...></lite-youtube>
|
|
71
|
+
// → <div data-youtube-video><iframe src="https://www.youtube.com/embed/X" allowfullscreen></iframe></div>
|
|
72
|
+
.replace(/<lite-youtube\s+videoid="([^"]*)"[^>]*>(?:<\/lite-youtube>)?/gi, '<div data-youtube-video=""><iframe src="https://www.youtube.com/embed/$1" allowfullscreen></iframe></div>')
|
|
73
|
+
// <embed src="X" type="application/pdf" ...>
|
|
74
|
+
// → <div data-pdf-src="X" data-pdf-name="filename">
|
|
75
|
+
.replace(/<embed\s+[^>]*src="([^"]*)"[^>]*type="application\/pdf"[^>]*\/?>/gi, (_match, src) => {
|
|
76
|
+
const name = src.split("/").pop()?.replace(/\?.*$/, "") || "PDF";
|
|
77
|
+
return `<div data-pdf-src="${src}" data-pdf-name="${name}"></div>`;
|
|
78
|
+
})
|
|
79
|
+
// Also handle reversed attribute order: type before src
|
|
80
|
+
.replace(/<embed\s+[^>]*type="application\/pdf"[^>]*src="([^"]*)"[^>]*\/?>/gi, (_match, src) => {
|
|
81
|
+
const name = src.split("/").pop()?.replace(/\?.*$/, "") || "PDF";
|
|
82
|
+
return `<div data-pdf-src="${src}" data-pdf-name="${name}"></div>`;
|
|
83
|
+
})
|
|
84
|
+
// <tiptap-file id="X">text</tiptap-file>
|
|
85
|
+
// → <div data-file-id="X" data-file-name="text">
|
|
86
|
+
.replace(/<tiptap-file\s+id="([^"]*)"[^>]*>([^<]*)<\/tiptap-file>/gi, (_match, id, text) => {
|
|
87
|
+
const name = text.trim() || "\uD30C\uC77C";
|
|
88
|
+
return `<div data-file-id="${id}" data-file-name="${name}"></div>`;
|
|
89
|
+
})
|
|
90
|
+
// <tiptap-midibus id="X" ...></tiptap-midibus> + 뒤따르는 iframe-wrapper 제거
|
|
91
|
+
// → <div data-youtube-video><iframe src="https://play.mbus.tv/v1/hls/X" allowfullscreen></iframe></div>
|
|
92
|
+
.replace(/<tiptap-midibus\s+id="([^"]*)"[^>]*>(?:<\/tiptap-midibus>)?/gi, (_match, id) => {
|
|
93
|
+
return `<div data-youtube-video=""><iframe src="https://play.mbus.tv/v1/hls/${id}" width="100%" height="400" frameborder="0" allowfullscreen="true"></iframe></div>`;
|
|
94
|
+
})
|
|
95
|
+
// 미디버스 뒤에 따라오는 빈 iframe-wrapper 제거
|
|
96
|
+
.replace(/<div\s+class="iframe-wrapper"[^>]*>[\s\S]*?<\/div>/gi, "")
|
|
97
|
+
// <div class="tiptap-columns"> → <div data-type="columns">
|
|
98
|
+
.replace(/<div\s+class="tiptap-columns">/gi, '<div data-type="columns">')
|
|
99
|
+
// <div class="tiptap-column"> → <div data-type="column">
|
|
100
|
+
.replace(/<div\s+class="tiptap-column">/gi, '<div data-type="column">')
|
|
101
|
+
// <tiptap-upload-skeleton ...> 제거
|
|
102
|
+
.replace(/<tiptap-upload-skeleton[^>]*>(?:<\/tiptap-upload-skeleton>)?/gi, ""));
|
|
103
|
+
}
|
|
104
|
+
export function stripHtmlToExcerpt(html, maxLen = 200) {
|
|
105
|
+
const text = String(html || "")
|
|
106
|
+
.replace(/<[^>]*>/g, " ")
|
|
107
|
+
.replace(/ /g, " ")
|
|
108
|
+
.replace(/&/g, "&")
|
|
109
|
+
.replace(/</g, "<")
|
|
110
|
+
.replace(/>/g, ">")
|
|
111
|
+
.replace(/"/g, '"')
|
|
112
|
+
.replace(/\s+/g, " ")
|
|
113
|
+
.trim();
|
|
114
|
+
return text.length > maxLen ? text.slice(0, maxLen) + "\u2026" : text;
|
|
115
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitize.test.d.ts","sourceRoot":"","sources":["../../src/utils/sanitize.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { transformLegacyHtml, sanitizeHtml, stripHtmlToExcerpt } from './sanitize';
|
|
3
|
+
describe('transformLegacyHtml', () => {
|
|
4
|
+
it('returns empty string for empty input', () => {
|
|
5
|
+
expect(transformLegacyHtml('')).toBe('');
|
|
6
|
+
});
|
|
7
|
+
it('returns null/undefined as-is', () => {
|
|
8
|
+
// @ts-expect-error testing null
|
|
9
|
+
expect(transformLegacyHtml(null)).toBe(null);
|
|
10
|
+
// @ts-expect-error testing undefined
|
|
11
|
+
expect(transformLegacyHtml(undefined)).toBe(undefined);
|
|
12
|
+
});
|
|
13
|
+
it('converts <tiptap-file id="X"> to <div data-file-id="X">', () => {
|
|
14
|
+
const input = '<tiptap-file id="abc123">report.pdf</tiptap-file>';
|
|
15
|
+
const result = transformLegacyHtml(input);
|
|
16
|
+
expect(result).toContain('data-file-id="abc123"');
|
|
17
|
+
expect(result).toContain('data-file-name="report.pdf"');
|
|
18
|
+
expect(result).not.toContain('tiptap-file');
|
|
19
|
+
});
|
|
20
|
+
it('converts <tiptap-file> with empty text to default name', () => {
|
|
21
|
+
const input = '<tiptap-file id="abc123"> </tiptap-file>';
|
|
22
|
+
const result = transformLegacyHtml(input);
|
|
23
|
+
expect(result).toContain('data-file-name="파일"');
|
|
24
|
+
});
|
|
25
|
+
it('converts <tiptap-midibus id="X"> to iframe', () => {
|
|
26
|
+
const input = '<tiptap-midibus id="vid123"></tiptap-midibus>';
|
|
27
|
+
const result = transformLegacyHtml(input);
|
|
28
|
+
expect(result).toContain('data-youtube-video');
|
|
29
|
+
expect(result).toContain('https://play.mbus.tv/v1/hls/vid123');
|
|
30
|
+
expect(result).toContain('iframe');
|
|
31
|
+
expect(result).not.toContain('tiptap-midibus');
|
|
32
|
+
});
|
|
33
|
+
it('converts <tiptap-collapsable title="X"> to <details><summary>', () => {
|
|
34
|
+
const input = '<tiptap-collapsable title="Click me">Hidden content</tiptap-collapsable>';
|
|
35
|
+
const result = transformLegacyHtml(input);
|
|
36
|
+
expect(result).toContain('<details>');
|
|
37
|
+
expect(result).toContain('<summary>Click me</summary>');
|
|
38
|
+
expect(result).toContain('Hidden content');
|
|
39
|
+
expect(result).not.toContain('tiptap-collapsable');
|
|
40
|
+
});
|
|
41
|
+
it('converts <lite-youtube videoid="X"> to YouTube iframe', () => {
|
|
42
|
+
const input = '<lite-youtube videoid="dQw4w9WgXcQ" params=""></lite-youtube>';
|
|
43
|
+
const result = transformLegacyHtml(input);
|
|
44
|
+
expect(result).toContain('https://www.youtube.com/embed/dQw4w9WgXcQ');
|
|
45
|
+
expect(result).toContain('data-youtube-video');
|
|
46
|
+
expect(result).not.toContain('lite-youtube');
|
|
47
|
+
});
|
|
48
|
+
it('converts <embed type="application/pdf"> (src before type)', () => {
|
|
49
|
+
const input = '<embed src="https://example.com/file.pdf" type="application/pdf" />';
|
|
50
|
+
const result = transformLegacyHtml(input);
|
|
51
|
+
expect(result).toContain('data-pdf-src="https://example.com/file.pdf"');
|
|
52
|
+
expect(result).toContain('data-pdf-name="file.pdf"');
|
|
53
|
+
expect(result).not.toContain('embed');
|
|
54
|
+
});
|
|
55
|
+
it('converts <embed type="application/pdf"> (type before src)', () => {
|
|
56
|
+
const input = '<embed type="application/pdf" src="https://example.com/doc.pdf" />';
|
|
57
|
+
const result = transformLegacyHtml(input);
|
|
58
|
+
expect(result).toContain('data-pdf-src="https://example.com/doc.pdf"');
|
|
59
|
+
expect(result).toContain('data-pdf-name="doc.pdf"');
|
|
60
|
+
});
|
|
61
|
+
it('converts <div class="tiptap-columns"> to <div data-type="columns">', () => {
|
|
62
|
+
const input = '<div class="tiptap-columns"><div class="tiptap-column">A</div></div>';
|
|
63
|
+
const result = transformLegacyHtml(input);
|
|
64
|
+
expect(result).toContain('data-type="columns"');
|
|
65
|
+
expect(result).toContain('data-type="column"');
|
|
66
|
+
expect(result).not.toContain('tiptap-columns');
|
|
67
|
+
expect(result).not.toContain('tiptap-column');
|
|
68
|
+
});
|
|
69
|
+
it('removes <tiptap-upload-skeleton>', () => {
|
|
70
|
+
const input = '<p>Before</p><tiptap-upload-skeleton data-id="x"></tiptap-upload-skeleton><p>After</p>';
|
|
71
|
+
const result = transformLegacyHtml(input);
|
|
72
|
+
expect(result).not.toContain('tiptap-upload-skeleton');
|
|
73
|
+
expect(result).toContain('Before');
|
|
74
|
+
expect(result).toContain('After');
|
|
75
|
+
});
|
|
76
|
+
it('removes iframe-wrapper divs after midibus conversion', () => {
|
|
77
|
+
const input = '<tiptap-midibus id="vid"></tiptap-midibus><div class="iframe-wrapper"><iframe></iframe></div>';
|
|
78
|
+
const result = transformLegacyHtml(input);
|
|
79
|
+
expect(result).not.toContain('iframe-wrapper');
|
|
80
|
+
});
|
|
81
|
+
it('parses margin-left: 40px via Indent extension (not transformLegacyHtml itself)', () => {
|
|
82
|
+
// transformLegacyHtml does NOT handle margin-left; the Indent extension does.
|
|
83
|
+
// This just verifies the function passes through margin-left style unchanged.
|
|
84
|
+
const input = '<p style="margin-left: 40px">Indented</p>';
|
|
85
|
+
const result = transformLegacyHtml(input);
|
|
86
|
+
expect(result).toContain('margin-left: 40px');
|
|
87
|
+
});
|
|
88
|
+
it('passes through normal HTML unchanged', () => {
|
|
89
|
+
const input = '<p>Hello <strong>world</strong></p>';
|
|
90
|
+
const result = transformLegacyHtml(input);
|
|
91
|
+
expect(result).toBe(input);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('sanitizeHtml', () => {
|
|
95
|
+
it('strips script tags', () => {
|
|
96
|
+
const input = '<p>Hello</p><script>alert("xss")</script><p>World</p>';
|
|
97
|
+
const result = sanitizeHtml(input);
|
|
98
|
+
expect(result).not.toContain('script');
|
|
99
|
+
expect(result).not.toContain('alert');
|
|
100
|
+
expect(result).toContain('<p>Hello</p>');
|
|
101
|
+
expect(result).toContain('<p>World</p>');
|
|
102
|
+
});
|
|
103
|
+
it('strips style tags', () => {
|
|
104
|
+
const input = '<style>body{color:red}</style><p>Text</p>';
|
|
105
|
+
const result = sanitizeHtml(input);
|
|
106
|
+
expect(result).not.toContain('style');
|
|
107
|
+
expect(result).toContain('<p>Text</p>');
|
|
108
|
+
});
|
|
109
|
+
it('keeps allowed tags', () => {
|
|
110
|
+
const input = '<p>Hello <strong>world</strong> <em>!</em></p>';
|
|
111
|
+
const result = sanitizeHtml(input);
|
|
112
|
+
expect(result).toContain('<p>');
|
|
113
|
+
expect(result).toContain('<strong>');
|
|
114
|
+
expect(result).toContain('<em>');
|
|
115
|
+
});
|
|
116
|
+
it('removes disallowed tags but keeps content', () => {
|
|
117
|
+
const input = '<p>Hello</p><form><input type="text"></form>';
|
|
118
|
+
const result = sanitizeHtml(input);
|
|
119
|
+
expect(result).not.toContain('form');
|
|
120
|
+
expect(result).not.toContain('input');
|
|
121
|
+
expect(result).toContain('<p>Hello</p>');
|
|
122
|
+
});
|
|
123
|
+
it('handles empty/null input', () => {
|
|
124
|
+
expect(sanitizeHtml('')).toBe('');
|
|
125
|
+
// @ts-expect-error testing null
|
|
126
|
+
expect(sanitizeHtml(null)).toBe('');
|
|
127
|
+
// @ts-expect-error testing undefined
|
|
128
|
+
expect(sanitizeHtml(undefined)).toBe('');
|
|
129
|
+
});
|
|
130
|
+
it('strips on* event handlers', () => {
|
|
131
|
+
const input = '<p onclick="alert(1)">Click</p>';
|
|
132
|
+
const result = sanitizeHtml(input);
|
|
133
|
+
expect(result).not.toContain('onclick');
|
|
134
|
+
expect(result).toContain('<p>');
|
|
135
|
+
});
|
|
136
|
+
it('allows safe href attributes on links', () => {
|
|
137
|
+
const input = '<a href="https://example.com" title="link">Link</a>';
|
|
138
|
+
const result = sanitizeHtml(input);
|
|
139
|
+
expect(result).toContain('href=');
|
|
140
|
+
expect(result).toContain('title=');
|
|
141
|
+
});
|
|
142
|
+
it('strips javascript: URLs', () => {
|
|
143
|
+
const input = '<a href="javascript:alert(1)">Bad</a>';
|
|
144
|
+
const result = sanitizeHtml(input);
|
|
145
|
+
expect(result).not.toContain('javascript:');
|
|
146
|
+
});
|
|
147
|
+
it('allows img with src', () => {
|
|
148
|
+
const input = '<img src="https://example.com/img.png" alt="test">';
|
|
149
|
+
const result = sanitizeHtml(input);
|
|
150
|
+
expect(result).toContain('src=');
|
|
151
|
+
expect(result).toContain('alt=');
|
|
152
|
+
});
|
|
153
|
+
it('preserves data-pdf-src on div', () => {
|
|
154
|
+
const input = '<div data-pdf-src="https://example.com/file.pdf" data-pdf-name="file.pdf"></div>';
|
|
155
|
+
const result = sanitizeHtml(input);
|
|
156
|
+
expect(result).toContain('data-pdf-src=');
|
|
157
|
+
expect(result).toContain('data-pdf-name=');
|
|
158
|
+
});
|
|
159
|
+
it('preserves table-related attributes', () => {
|
|
160
|
+
const input = '<td colspan="2" rowspan="1">Cell</td>';
|
|
161
|
+
const result = sanitizeHtml(input);
|
|
162
|
+
expect(result).toContain('colspan=');
|
|
163
|
+
expect(result).toContain('rowspan=');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe('stripHtmlToExcerpt', () => {
|
|
167
|
+
it('strips all HTML tags', () => {
|
|
168
|
+
const input = '<p>Hello <strong>world</strong></p>';
|
|
169
|
+
const result = stripHtmlToExcerpt(input);
|
|
170
|
+
expect(result).toBe('Hello world');
|
|
171
|
+
});
|
|
172
|
+
it('truncates to specified length', () => {
|
|
173
|
+
const input = '<p>' + 'a'.repeat(300) + '</p>';
|
|
174
|
+
const result = stripHtmlToExcerpt(input, 100);
|
|
175
|
+
expect(result.length).toBe(101); // 100 chars + ellipsis
|
|
176
|
+
expect(result.endsWith('\u2026')).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
it('truncates to default 200 length', () => {
|
|
179
|
+
const input = '<p>' + 'b'.repeat(300) + '</p>';
|
|
180
|
+
const result = stripHtmlToExcerpt(input);
|
|
181
|
+
expect(result.length).toBe(201); // 200 + ellipsis
|
|
182
|
+
});
|
|
183
|
+
it('does not truncate short text', () => {
|
|
184
|
+
const input = '<p>Short</p>';
|
|
185
|
+
const result = stripHtmlToExcerpt(input);
|
|
186
|
+
expect(result).toBe('Short');
|
|
187
|
+
});
|
|
188
|
+
it('handles empty input', () => {
|
|
189
|
+
expect(stripHtmlToExcerpt('')).toBe('');
|
|
190
|
+
// @ts-expect-error testing null
|
|
191
|
+
expect(stripHtmlToExcerpt(null)).toBe('');
|
|
192
|
+
});
|
|
193
|
+
it('decodes HTML entities', () => {
|
|
194
|
+
const input = '& < > " ';
|
|
195
|
+
const result = stripHtmlToExcerpt(input);
|
|
196
|
+
expect(result).toContain('&');
|
|
197
|
+
expect(result).toContain('<');
|
|
198
|
+
expect(result).toContain('>');
|
|
199
|
+
expect(result).toContain('"');
|
|
200
|
+
});
|
|
201
|
+
it('collapses whitespace', () => {
|
|
202
|
+
const input = '<p>Hello \n\t world</p>';
|
|
203
|
+
const result = stripHtmlToExcerpt(input);
|
|
204
|
+
expect(result).toBe('Hello world');
|
|
205
|
+
});
|
|
206
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@teriusu/rich-editor",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "TipTap 기반 Notion-style 리치 텍스트 에디터 (Svelte)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"svelte": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"svelte": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./styles": {
|
|
15
|
+
"style": "./dist/styles/editor.css",
|
|
16
|
+
"default": "./dist/styles/editor.css"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "svelte-package -i src",
|
|
24
|
+
"dev": "svelte-package -i src --watch",
|
|
25
|
+
"dev:preview": "vite --config vite.config.dev.ts",
|
|
26
|
+
"test": "vitest run"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"svelte": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@tiptap/core": "^3.20.4",
|
|
33
|
+
"@tiptap/extension-bubble-menu": "^3.20.4",
|
|
34
|
+
"@tiptap/extension-character-count": "^3.21.0",
|
|
35
|
+
"@tiptap/extension-code-block-lowlight": "^3.20.5",
|
|
36
|
+
"@tiptap/extension-color": "^3.20.4",
|
|
37
|
+
"@tiptap/extension-details": "^3.21.0",
|
|
38
|
+
"@tiptap/extension-details-content": "^3.0.0-beta.11",
|
|
39
|
+
"@tiptap/extension-details-summary": "^3.0.0-beta.11",
|
|
40
|
+
"@tiptap/extension-file-handler": "^3.21.0",
|
|
41
|
+
"@tiptap/extension-focus": "^3.21.0",
|
|
42
|
+
"@tiptap/extension-highlight": "^3.20.4",
|
|
43
|
+
"@tiptap/extension-image": "^3.20.4",
|
|
44
|
+
"@tiptap/extension-link": "^3.20.4",
|
|
45
|
+
"@tiptap/extension-placeholder": "^3.20.4",
|
|
46
|
+
"@tiptap/extension-subscript": "^3.21.0",
|
|
47
|
+
"@tiptap/extension-superscript": "^3.21.0",
|
|
48
|
+
"@tiptap/extension-table": "^3.20.5",
|
|
49
|
+
"@tiptap/extension-table-cell": "^3.20.5",
|
|
50
|
+
"@tiptap/extension-table-header": "^3.20.5",
|
|
51
|
+
"@tiptap/extension-table-row": "^3.20.5",
|
|
52
|
+
"@tiptap/extension-task-item": "^3.21.0",
|
|
53
|
+
"@tiptap/extension-task-list": "^3.21.0",
|
|
54
|
+
"@tiptap/extension-text-align": "^3.20.4",
|
|
55
|
+
"@tiptap/extension-text-style": "^3.20.4",
|
|
56
|
+
"@tiptap/extension-typography": "^3.21.0",
|
|
57
|
+
"@tiptap/extension-underline": "^3.20.4",
|
|
58
|
+
"@tiptap/extension-youtube": "^3.21.0",
|
|
59
|
+
"@tiptap/pm": "^3.20.4",
|
|
60
|
+
"@tiptap/starter-kit": "^3.22.0",
|
|
61
|
+
"clsx": "^2.1.1",
|
|
62
|
+
"highlight.js": "^11.11.1",
|
|
63
|
+
"lowlight": "^3.3.0",
|
|
64
|
+
"lucide-svelte": "^0.477.0",
|
|
65
|
+
"tailwind-merge": "^3.0.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@sveltejs/package": "^2.3.0",
|
|
69
|
+
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
70
|
+
"jsdom": "^29.0.1",
|
|
71
|
+
"svelte": "^5.0.0",
|
|
72
|
+
"typescript": "^5.7.0",
|
|
73
|
+
"vite": "^6.0.0",
|
|
74
|
+
"vitest": "^4.1.2"
|
|
75
|
+
}
|
|
76
|
+
}
|