@teriusu/rich-editor 0.10.8 → 0.11.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/dist/components/BubbleToolbar.svelte +40 -36
- package/dist/components/BubbleToolbar.svelte.d.ts +3 -0
- package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -1
- package/dist/components/EmojiSuggestionMenu.svelte +291 -0
- package/dist/components/EmojiSuggestionMenu.svelte.d.ts +9 -0
- package/dist/components/EmojiSuggestionMenu.svelte.d.ts.map +1 -0
- package/dist/components/FixedToolbar.svelte +53 -47
- package/dist/components/FixedToolbar.svelte.d.ts +3 -0
- package/dist/components/FixedToolbar.svelte.d.ts.map +1 -1
- package/dist/components/FloatingHelper.svelte +219 -0
- package/dist/components/FloatingHelper.svelte.d.ts +14 -0
- package/dist/components/FloatingHelper.svelte.d.ts.map +1 -0
- package/dist/components/InputModal.svelte +7 -3
- package/dist/components/InputModal.svelte.d.ts +3 -0
- package/dist/components/InputModal.svelte.d.ts.map +1 -1
- package/dist/components/MathModal.svelte +33 -29
- package/dist/components/MathModal.svelte.d.ts +3 -0
- package/dist/components/MathModal.svelte.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.svelte +7 -3
- package/dist/components/MediaPickerModal.svelte.d.ts +3 -0
- package/dist/components/MediaPickerModal.svelte.d.ts.map +1 -1
- package/dist/components/SlashCommandMenu.svelte +88 -72
- package/dist/components/SlashCommandMenu.svelte.d.ts +3 -0
- package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -1
- package/dist/components/TableBubbleMenu.svelte +41 -33
- package/dist/components/TableBubbleMenu.svelte.d.ts +3 -0
- package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -1
- package/dist/components/TipTapEditor.svelte +239 -127
- package/dist/components/TipTapEditor.svelte.d.ts +7 -0
- package/dist/components/TipTapEditor.svelte.d.ts.map +1 -1
- package/dist/extensions/CardBlock.d.ts.map +1 -1
- package/dist/extensions/CardBlock.js +6 -4
- package/dist/extensions/FileAttachment.d.ts.map +1 -1
- package/dist/extensions/FileAttachment.js +2 -1
- package/dist/extensions/FixedDetails.d.ts.map +1 -1
- package/dist/extensions/FixedDetails.js +5 -3
- package/dist/extensions/LegacyBlock.d.ts.map +1 -1
- package/dist/extensions/LegacyBlock.js +8 -6
- package/dist/extensions/Math.d.ts.map +1 -1
- package/dist/extensions/Math.js +5 -2
- package/dist/extensions/MbusVideo.d.ts.map +1 -1
- package/dist/extensions/MbusVideo.js +73 -9
- package/dist/extensions/MediaToolbar.d.ts +41 -0
- package/dist/extensions/MediaToolbar.d.ts.map +1 -0
- package/dist/extensions/MediaToolbar.js +196 -0
- package/dist/extensions/MediaToolbar.test.d.ts +2 -0
- package/dist/extensions/MediaToolbar.test.d.ts.map +1 -0
- package/dist/extensions/MediaToolbar.test.js +231 -0
- package/dist/extensions/OrderedListMarker.d.ts +2 -0
- package/dist/extensions/OrderedListMarker.d.ts.map +1 -0
- package/dist/extensions/OrderedListMarker.js +114 -0
- package/dist/extensions/OrderedListMarker.test.d.ts +2 -0
- package/dist/extensions/OrderedListMarker.test.d.ts.map +1 -0
- package/dist/extensions/OrderedListMarker.test.js +176 -0
- package/dist/extensions/PdfBlock.d.ts.map +1 -1
- package/dist/extensions/PdfBlock.js +25 -23
- package/dist/extensions/ResizableImage.d.ts.map +1 -1
- package/dist/extensions/ResizableImage.js +20 -1
- package/dist/extensions/TableKeymap.d.ts +16 -0
- package/dist/extensions/TableKeymap.d.ts.map +1 -0
- package/dist/extensions/TableKeymap.js +97 -0
- package/dist/extensions/TableKeymap.test.d.ts +2 -0
- package/dist/extensions/TableKeymap.test.d.ts.map +1 -0
- package/dist/extensions/TableKeymap.test.js +147 -0
- package/dist/extensions/TabsBlock.d.ts.map +1 -1
- package/dist/extensions/TabsBlock.js +12 -9
- package/dist/extensions/TiptapMidibus.d.ts.map +1 -1
- package/dist/extensions/TiptapMidibus.js +60 -6
- package/dist/extensions/UploadSkeleton.d.ts +61 -0
- package/dist/extensions/UploadSkeleton.d.ts.map +1 -0
- package/dist/extensions/UploadSkeleton.js +233 -0
- package/dist/extensions/UploadSkeleton.test.d.ts +2 -0
- package/dist/extensions/UploadSkeleton.test.d.ts.map +1 -0
- package/dist/extensions/UploadSkeleton.test.js +128 -0
- package/dist/extensions/VideoEmbed.d.ts +8 -0
- package/dist/extensions/VideoEmbed.d.ts.map +1 -1
- package/dist/extensions/VideoEmbed.js +226 -45
- package/dist/extensions/VideoEmbed.test.js +131 -2
- package/dist/i18n/en.d.ts +3 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +191 -0
- package/dist/i18n/es.d.ts +3 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +191 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +109 -0
- package/dist/i18n/index.test.d.ts +2 -0
- package/dist/i18n/index.test.d.ts.map +1 -0
- package/dist/i18n/index.test.js +173 -0
- package/dist/i18n/ja.d.ts +3 -0
- package/dist/i18n/ja.d.ts.map +1 -0
- package/dist/i18n/ja.js +191 -0
- package/dist/i18n/ko.d.ts +198 -0
- package/dist/i18n/ko.d.ts.map +1 -0
- package/dist/i18n/ko.js +215 -0
- package/dist/i18n/zh-hans.d.ts +3 -0
- package/dist/i18n/zh-hans.d.ts.map +1 -0
- package/dist/i18n/zh-hans.js +191 -0
- package/dist/i18n/zh-hant.d.ts +3 -0
- package/dist/i18n/zh-hant.d.ts.map +1 -0
- package/dist/i18n/zh-hant.js +192 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/static/highlight.d.ts +13 -0
- package/dist/static/highlight.d.ts.map +1 -0
- package/dist/static/highlight.js +99 -0
- package/dist/static/index.d.ts +33 -0
- package/dist/static/index.d.ts.map +1 -0
- package/dist/static/index.js +367 -0
- package/dist/static/index.test.d.ts +2 -0
- package/dist/static/index.test.d.ts.map +1 -0
- package/dist/static/index.test.js +194 -0
- package/dist/static/tokenizer.d.ts +52 -0
- package/dist/static/tokenizer.d.ts.map +1 -0
- package/dist/static/tokenizer.js +289 -0
- package/dist/styles/editor.css +1 -1
- package/dist/utils/emoji-data.d.ts +15 -0
- package/dist/utils/emoji-suggestion.d.ts +48 -0
- package/dist/utils/emoji-suggestion.d.ts.map +1 -0
- package/dist/utils/emoji-suggestion.js +51 -0
- package/dist/utils/emoji-suggestion.test.d.ts +2 -0
- package/dist/utils/emoji-suggestion.test.d.ts.map +1 -0
- package/dist/utils/emoji-suggestion.test.js +117 -0
- package/dist/utils/floating-helper.d.ts +60 -0
- package/dist/utils/floating-helper.d.ts.map +1 -0
- package/dist/utils/floating-helper.js +63 -0
- package/dist/utils/floating-helper.test.d.ts +2 -0
- package/dist/utils/floating-helper.test.d.ts.map +1 -0
- package/dist/utils/floating-helper.test.js +141 -0
- package/dist/utils/guard-vscode-paste.d.ts +19 -0
- package/dist/utils/guard-vscode-paste.d.ts.map +1 -0
- package/dist/utils/guard-vscode-paste.js +46 -0
- package/dist/utils/guard-vscode-paste.test.d.ts +2 -0
- package/dist/utils/guard-vscode-paste.test.d.ts.map +1 -0
- package/dist/utils/guard-vscode-paste.test.js +86 -0
- package/dist/utils/media-size.d.ts +29 -0
- package/dist/utils/media-size.d.ts.map +1 -0
- package/dist/utils/media-size.js +90 -0
- package/dist/utils/resize.d.ts +12 -0
- package/dist/utils/resize.d.ts.map +1 -1
- package/dist/utils/resize.js +5 -5
- package/dist/utils/sanitize.d.ts +2 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +28 -3
- package/package.json +8 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import katex from 'katex';
|
|
3
|
+
import { renderStaticHtml, sanitizeHtml, transformLegacyHtml, stripHtmlToExcerpt } from './index';
|
|
4
|
+
/** `Math.ts`의 `renderKatex`와 같은 옵션 — 기대값을 같은 함수로 만들어 비교한다. */
|
|
5
|
+
const KATEX_OPTIONS = {
|
|
6
|
+
throwOnError: false,
|
|
7
|
+
strict: false,
|
|
8
|
+
output: 'html'
|
|
9
|
+
};
|
|
10
|
+
/** 태그를 걷어내고 기본 엔티티를 디코드 — 하이라이트가 글자를 바꾸지 않았는지 확인용. */
|
|
11
|
+
function textOf(html) {
|
|
12
|
+
return html
|
|
13
|
+
.replace(/<[^>]*>/g, '')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/&/g, '&');
|
|
18
|
+
}
|
|
19
|
+
describe('renderStaticHtml — 코드 블록 하이라이트', () => {
|
|
20
|
+
it('language-* 코드에 문법 색을 입히고 구조·글자는 보존한다 (엔티티 왕복 포함)', () => {
|
|
21
|
+
const input = '<pre><code class="language-cpp">#include <iostream>\nint main() { return 0; }</code></pre>';
|
|
22
|
+
const result = renderStaticHtml(input);
|
|
23
|
+
expect(result).toContain('class="hljs-');
|
|
24
|
+
// 색은 code 안에 — pre·code 태그와 클래스는 원문 그대로
|
|
25
|
+
expect(result).toMatch(/^<pre><code class="language-cpp">/);
|
|
26
|
+
expect(result).toMatch(/<\/code><\/pre>$/);
|
|
27
|
+
// 글자 왕복: 태그를 걷어내면 원문 코드 그대로
|
|
28
|
+
expect(textOf(result)).toBe('#include <iostream>\nint main() { return 0; }');
|
|
29
|
+
});
|
|
30
|
+
it('code 자식이 없는 옛 brush 저장본은 pre 자신에 색을 넣는다', () => {
|
|
31
|
+
const input = '<pre class="brush:c++; toolbar:false;">int a = 1;</pre>';
|
|
32
|
+
const result = renderStaticHtml(input, { defaultLanguage: 'cpp' });
|
|
33
|
+
// brush 클래스에서 언어를 추측하지 않고(defaultLanguage로) 태그는 원문 그대로
|
|
34
|
+
expect(result).toMatch(/^<pre class="brush:c\+\+; toolbar:false;">/);
|
|
35
|
+
expect(result).toContain('hljs-');
|
|
36
|
+
expect(textOf(result)).toBe('int a = 1;');
|
|
37
|
+
});
|
|
38
|
+
it('이미 요소가 섞인 저장본은 건너뛴다', () => {
|
|
39
|
+
const spans = '<pre><code class="language-cpp"><span class="hljs-keyword">int</span> a;</code></pre>';
|
|
40
|
+
expect(renderStaticHtml(spans)).toBe(spans);
|
|
41
|
+
const br = '<pre><code>line1<br>line2</code></pre>';
|
|
42
|
+
expect(renderStaticHtml(br)).toBe(br);
|
|
43
|
+
});
|
|
44
|
+
it('언어 미지정 코드는 자동 감지로 칠한다', () => {
|
|
45
|
+
const input = '<pre><code>#include <cstdio>\nint main() { printf("hi"); }</code></pre>';
|
|
46
|
+
const result = renderStaticHtml(input);
|
|
47
|
+
expect(result).toContain('hljs-');
|
|
48
|
+
expect(textOf(result)).toBe('#include <cstdio>\nint main() { printf("hi"); }');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('renderStaticHtml — 블록 사이 공백', () => {
|
|
52
|
+
it('블록 사이 개행 전용 텍스트를 지운다', () => {
|
|
53
|
+
expect(renderStaticHtml('<p>a</p>\n\n<p>b</p>')).toBe('<p>a</p><p>b</p>');
|
|
54
|
+
});
|
|
55
|
+
it('인라인 사이 공백은 보존한다', () => {
|
|
56
|
+
const input = '<p><strong>a</strong> <em>b</em></p>\n<p>c</p>';
|
|
57
|
+
expect(renderStaticHtml(input)).toBe('<p><strong>a</strong> <em>b</em></p><p>c</p>');
|
|
58
|
+
});
|
|
59
|
+
it('pre·code 안의 개행은 내용이라 건드리지 않는다', () => {
|
|
60
|
+
const input = '<pre><code class="language-plaintext">a\n\nb</code></pre>\n<p>c</p>';
|
|
61
|
+
const result = renderStaticHtml(input);
|
|
62
|
+
expect(result).toContain('a\n\nb');
|
|
63
|
+
expect(result).not.toContain('</pre>\n<p>');
|
|
64
|
+
});
|
|
65
|
+
it('` ` 섞인 공백 전용 틈도 지운다 (U+00A0도 `\\s`다 — DOM 판정과 동일)', () => {
|
|
66
|
+
/*
|
|
67
|
+
* ⚠️ 빠른 탈출 게이트(`>\s*[\n\r]\s*<`)는 **원문 문자열**을 보므로 ` ` 리터럴이
|
|
68
|
+
* 낀 틈만으로는 열리지 않는다(앱 DOM 버전과 같은 동작). 다른 틈의 순수 개행이
|
|
69
|
+
* 게이트를 열면, ` ` 틈도 디코드 후 공백 전용으로 판정돼 지워진다.
|
|
70
|
+
*/
|
|
71
|
+
expect(renderStaticHtml('<p>a</p> \n<p>b</p>\n<p>c</p>')).toBe('<p>a</p><p>b</p><p>c</p>');
|
|
72
|
+
});
|
|
73
|
+
it('짝 잃은 닫는 태그는 원문 그대로 흘려보낸다', () => {
|
|
74
|
+
expect(renderStaticHtml('<p>a</p>\n</div><p>b</p>')).toBe('<p>a</p></div><p>b</p>');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('renderStaticHtml — 수식', () => {
|
|
78
|
+
it('인라인 수식을 KaTeX로 렌더하고 바깥 태그는 속성째 보존한다', () => {
|
|
79
|
+
const input = '<math-inline class="math-node">x^2</math-inline>';
|
|
80
|
+
const expected = `<math-inline class="math-node">${katex.renderToString('x^2', {
|
|
81
|
+
...KATEX_OPTIONS,
|
|
82
|
+
displayMode: false
|
|
83
|
+
})}</math-inline>`;
|
|
84
|
+
expect(renderStaticHtml(input)).toBe(expected);
|
|
85
|
+
});
|
|
86
|
+
it('디스플레이 수식은 displayMode로 렌더한다', () => {
|
|
87
|
+
const input = '<math-display class="math-node">\\sum_{i=1}^{n} i</math-display>';
|
|
88
|
+
const result = renderStaticHtml(input);
|
|
89
|
+
expect(result).toMatch(/^<math-display class="math-node">/);
|
|
90
|
+
expect(result).toContain('katex-display');
|
|
91
|
+
});
|
|
92
|
+
it('latex의 HTML 엔티티를 디코드한 뒤 렌더한다', () => {
|
|
93
|
+
const input = '<math-inline>a < b</math-inline>';
|
|
94
|
+
const expected = `<math-inline>${katex.renderToString('a < b', {
|
|
95
|
+
...KATEX_OPTIONS,
|
|
96
|
+
displayMode: false
|
|
97
|
+
})}</math-inline>`;
|
|
98
|
+
expect(renderStaticHtml(input)).toBe(expected);
|
|
99
|
+
});
|
|
100
|
+
it('빈 수식은 그대로 둔다', () => {
|
|
101
|
+
const input = '<math-inline class="math-node"></math-inline>';
|
|
102
|
+
expect(renderStaticHtml(input)).toBe(input);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('renderStaticHtml — rewriteAssetUrl', () => {
|
|
106
|
+
it('src·href를 태그·속성 문맥과 함께 넘기고 바뀐 것만 고쳐 쓴다', () => {
|
|
107
|
+
const calls = [];
|
|
108
|
+
const input = '<p><img src="https://s.jungol.co.kr/a.png"><a href="/files/f.pdf">f</a></p>';
|
|
109
|
+
const result = renderStaticHtml(input, {
|
|
110
|
+
rewriteAssetUrl: (url, context) => {
|
|
111
|
+
calls.push({ url, ...context });
|
|
112
|
+
return url.replace('https://s.jungol.co.kr', '/static/s');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
expect(result).toBe('<p><img src="/static/s/a.png"><a href="/files/f.pdf">f</a></p>');
|
|
116
|
+
expect(calls).toEqual([
|
|
117
|
+
{ url: 'https://s.jungol.co.kr/a.png', tag: 'img', attr: 'src' },
|
|
118
|
+
{ url: '/files/f.pdf', tag: 'a', attr: 'href' }
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
it('srcset은 후보마다 부르고, data URL 속 쉼표는 구분자가 아니다', () => {
|
|
122
|
+
const urls = [];
|
|
123
|
+
const input = '<img srcset="https://x/a.png 1x, data:image/png;base64,AA==, https://x/b.png 2x">';
|
|
124
|
+
const result = renderStaticHtml(input, {
|
|
125
|
+
rewriteAssetUrl: (url) => {
|
|
126
|
+
urls.push(url);
|
|
127
|
+
return url;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
expect(urls).toEqual(['https://x/a.png', 'data:image/png;base64,AA==', 'https://x/b.png']);
|
|
131
|
+
expect(result).toBe(input);
|
|
132
|
+
});
|
|
133
|
+
it('style·background는 url(…) 안쪽만 바꾼다', () => {
|
|
134
|
+
const input = '<div style="background-image:url("https://x/b.png");color:red">x</div>' +
|
|
135
|
+
'<table background="url(https://x/c.png)"><tbody><tr><td>y</td></tr></tbody></table>';
|
|
136
|
+
const result = renderStaticHtml(input, {
|
|
137
|
+
rewriteAssetUrl: (url) => url.replace('https://x', '/cdn')
|
|
138
|
+
});
|
|
139
|
+
expect(result).toContain('style="background-image:url("/cdn/b.png");color:red"');
|
|
140
|
+
expect(result).toContain('background="url("/cdn/c.png")"');
|
|
141
|
+
});
|
|
142
|
+
it('아무것도 안 바뀌면 원본 문자열을 그대로 돌려준다', () => {
|
|
143
|
+
const input = '<p><img src="/local/a.png" alt="x"></p>';
|
|
144
|
+
expect(renderStaticHtml(input, { rewriteAssetUrl: (url) => url })).toBe(input);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('renderStaticHtml — 결정성·SSR 안전', () => {
|
|
148
|
+
const combined = [
|
|
149
|
+
'<h2>제목</h2>',
|
|
150
|
+
'<pre><code class="language-cpp">int main() { return 0; }</code></pre>',
|
|
151
|
+
'<math-display class="math-node">\\frac{1}{2}</math-display>',
|
|
152
|
+
'<p><img src="https://s.jungol.co.kr/a.png"> 그림</p>'
|
|
153
|
+
].join('\n');
|
|
154
|
+
const options = {
|
|
155
|
+
defaultLanguage: 'cpp',
|
|
156
|
+
rewriteAssetUrl: (url) => url.replace('https://s.jungol.co.kr', '/static/s')
|
|
157
|
+
};
|
|
158
|
+
it('같은 입력 두 번 = 같은 출력', () => {
|
|
159
|
+
const first = renderStaticHtml(combined, options);
|
|
160
|
+
expect(renderStaticHtml(combined, options)).toBe(first);
|
|
161
|
+
// 네 단계가 전부 실제로 일했는지도 같이 확인한다
|
|
162
|
+
expect(first).toContain('hljs-');
|
|
163
|
+
expect(first).toContain('katex');
|
|
164
|
+
expect(first).toContain('src="/static/s/a.png"');
|
|
165
|
+
expect(first).not.toContain('>\n<');
|
|
166
|
+
});
|
|
167
|
+
it('한 번 처리한 출력을 다시 넣어도 그대로다', () => {
|
|
168
|
+
const first = renderStaticHtml(combined, options);
|
|
169
|
+
expect(renderStaticHtml(first, options)).toBe(first);
|
|
170
|
+
});
|
|
171
|
+
it('빈 입력은 그대로 돌려준다', () => {
|
|
172
|
+
expect(renderStaticHtml('')).toBe('');
|
|
173
|
+
});
|
|
174
|
+
it('DOM·브라우저 전역 없이 동작한다', () => {
|
|
175
|
+
vi.stubGlobal('document', undefined);
|
|
176
|
+
vi.stubGlobal('window', undefined);
|
|
177
|
+
vi.stubGlobal('DOMParser', undefined);
|
|
178
|
+
try {
|
|
179
|
+
const result = renderStaticHtml('<pre><code class="language-cpp">int a;</code></pre>\n<math-inline>x_1</math-inline>', { rewriteAssetUrl: (url) => url });
|
|
180
|
+
expect(result).toContain('hljs-');
|
|
181
|
+
expect(result).toContain('katex');
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
vi.unstubAllGlobals();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe('sanitize 유틸 재수출', () => {
|
|
189
|
+
it('sanitizeHtml·transformLegacyHtml·stripHtmlToExcerpt를 모듈 직접 경로에서 다시 내보낸다', () => {
|
|
190
|
+
expect(sanitizeHtml('<p onclick="x()">a</p><script>bad()</script>')).toBe('<p>a</p>');
|
|
191
|
+
expect(transformLegacyHtml('')).toBe('');
|
|
192
|
+
expect(stripHtmlToExcerpt('<p>hello world</p>')).toBe('hello world');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface StaticText {
|
|
2
|
+
type: "text";
|
|
3
|
+
/** 원문 그대로 — 엔티티가 살아 있다. 글자로 읽을 때는 `decodeEntities`를 거친다. */
|
|
4
|
+
raw: string;
|
|
5
|
+
}
|
|
6
|
+
/** 주석·독타입·짝 잃은 닫는 태그·주입된 HTML(하이라이트·KaTeX) 등 "그대로 내보내는" 조각. */
|
|
7
|
+
export interface StaticRaw {
|
|
8
|
+
type: "raw";
|
|
9
|
+
raw: string;
|
|
10
|
+
}
|
|
11
|
+
export interface StaticAttr {
|
|
12
|
+
/** 소문자 이름 — 조회용. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** 원문 표기 이름 — 태그를 다시 조립할 때 쓴다. */
|
|
15
|
+
rawName: string;
|
|
16
|
+
/** 디코드된 값. `null`이면 값 없는 속성(`<details open>`). */
|
|
17
|
+
value: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface StaticElement {
|
|
20
|
+
type: "element";
|
|
21
|
+
/** 소문자 태그 이름. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** 원문 표기 이름 — 태그를 다시 조립할 때 쓴다. */
|
|
24
|
+
rawName: string;
|
|
25
|
+
attrs: StaticAttr[];
|
|
26
|
+
/** 여는 태그 원문(`<pre class="…">`). `dirty`가 아니면 이대로 내보낸다. */
|
|
27
|
+
rawTag: string;
|
|
28
|
+
/** 닫는 태그 원문. 없으면(void·self-closing·미닫힘) 빈 문자열. */
|
|
29
|
+
endTag: string;
|
|
30
|
+
selfClosing: boolean;
|
|
31
|
+
/** 속성이 바뀌어 여는 태그를 다시 조립해야 하는가. */
|
|
32
|
+
dirty: boolean;
|
|
33
|
+
children: StaticNode[];
|
|
34
|
+
}
|
|
35
|
+
export type StaticNode = StaticText | StaticRaw | StaticElement;
|
|
36
|
+
export interface StaticRoot {
|
|
37
|
+
children: StaticNode[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* HTML 엔티티 디코드. 한 번의 치환 패스라 `&lt;`는 `<`가 된다(이중 디코드 없음) —
|
|
41
|
+
* latex·코드 원문에 흔한 `<`·`&`를 정확히 되살리는 데 필요한 성질이다.
|
|
42
|
+
*/
|
|
43
|
+
export declare function decodeEntities(value: string): string;
|
|
44
|
+
export declare function parseHtml(html: string): StaticRoot;
|
|
45
|
+
export declare function serializeHtml(root: StaticRoot): string;
|
|
46
|
+
/** DOM `getAttribute`처럼 — 첫 동명 속성, 값 없는 속성은 `""`, 없으면 `null`. */
|
|
47
|
+
export declare function getAttribute(element: StaticElement, name: string): string | null;
|
|
48
|
+
export declare function setAttribute(element: StaticElement, name: string, value: string): void;
|
|
49
|
+
export declare function firstElementChild(element: StaticElement): StaticElement | null;
|
|
50
|
+
/** DOM `textContent`처럼 자손 텍스트를 디코드해 잇는다. 주석·raw 조각은 세지 않는다. */
|
|
51
|
+
export declare function textContent(element: StaticElement): string;
|
|
52
|
+
//# sourceMappingURL=tokenizer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenizer.d.ts","sourceRoot":"","sources":["../../src/static/tokenizer.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,GAAG,EAAE,MAAM,CAAC;CACb;AAED,+DAA+D;AAC/D,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,KAAK,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,UAAU;IACzB,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,kDAAkD;IAClD,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,SAAS,CAAC;IAChB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,0DAA0D;IAC1D,MAAM,EAAE,MAAM,CAAC;IACf,kDAAkD;IAClD,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,OAAO,CAAC;IACrB,kCAAkC;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,QAAQ,EAAE,UAAU,EAAE,CAAC;CACxB;AAED,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,aAAa,CAAC;AAEhE,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,UAAU,EAAE,CAAC;CACxB;AAmCD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAWpD;AA8CD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAyHlD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAatD;AAYD,iEAAiE;AACjE,wBAAgB,YAAY,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIhF;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAKtF;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,aAAa,GAAG,aAAa,GAAG,IAAI,CAK9E;AAED,+DAA+D;AAC/D,wBAAgB,WAAW,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAQ1D"}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR-safe 경량 HTML 토크나이저 + 얕은 트리.
|
|
3
|
+
*
|
|
4
|
+
* `renderStaticHtml` 전용이다 — 범용 파서가 아니다. DOM(`DOMParser`) 없이 문자열만으로
|
|
5
|
+
* 다음 세 가지를 판정하려고 만들었다:
|
|
6
|
+
*
|
|
7
|
+
* 1. 요소의 **자식 요소 유무** — 이미 하이라이트가 섞인 코드 판을 건너뛰는 판정
|
|
8
|
+
* 2. 텍스트 노드의 **형제가 블록인지** — 블록 사이 공백만 지우고 인라인 공백은 남기는 판정
|
|
9
|
+
* 3. 태그의 **속성 읽기·고쳐쓰기** — asset URL 치환
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **손대지 않은 노드는 원문 그대로 다시 내보낸다.** 요소는 여는 태그 원문(`rawTag`)을
|
|
12
|
+
* 보관하고, 속성을 실제로 바꾼 요소만(`dirty`) 태그를 다시 조립한다 — DOMParser 왕복이
|
|
13
|
+
* 일으키는 전면 재직렬화(속성 순서·따옴표·공백 정규화)를 피하고, 같은 입력이면 같은
|
|
14
|
+
* 출력이 바이트 단위로 보장된다.
|
|
15
|
+
*
|
|
16
|
+
* 관대한 파서다: 짝 잃은 닫는 태그는 원문 그대로 흘려보내고(DOM 은 버리지만 여기선 보존이
|
|
17
|
+
* 더 안전하다), 닫히지 않은 요소는 조상이 닫힐 때 같이 닫힌 것으로 본다. 에디터 저장본
|
|
18
|
+
* (정형 HTML)에서는 어느 분기도 타지 않는다.
|
|
19
|
+
*/
|
|
20
|
+
import { escapeHtml } from "../utils/escape-html";
|
|
21
|
+
const VOID_ELEMENTS = new Set([
|
|
22
|
+
"area",
|
|
23
|
+
"base",
|
|
24
|
+
"br",
|
|
25
|
+
"col",
|
|
26
|
+
"embed",
|
|
27
|
+
"hr",
|
|
28
|
+
"img",
|
|
29
|
+
"input",
|
|
30
|
+
"link",
|
|
31
|
+
"meta",
|
|
32
|
+
"param",
|
|
33
|
+
"source",
|
|
34
|
+
"track",
|
|
35
|
+
"wbr",
|
|
36
|
+
]);
|
|
37
|
+
/** 내용을 마크업으로 파싱하지 않는 요소 — 닫는 태그까지 안을 원문(raw)으로 보관한다. */
|
|
38
|
+
const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
|
|
39
|
+
/**
|
|
40
|
+
* 에디터 직렬화가 만드는 다섯 이름만 안다. 그 밖의 이름은 원문 그대로 남긴다 — 모르는 것을
|
|
41
|
+
* 반쯤 디코드하는 쪽이 더 위험하다. 숫자 참조(`{`·``)는 전부 디코드한다.
|
|
42
|
+
*/
|
|
43
|
+
const NAMED_ENTITIES = {
|
|
44
|
+
amp: "&",
|
|
45
|
+
lt: "<",
|
|
46
|
+
gt: ">",
|
|
47
|
+
quot: '"',
|
|
48
|
+
apos: "'",
|
|
49
|
+
nbsp: "\u00a0",
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* HTML 엔티티 디코드. 한 번의 치환 패스라 `&lt;`는 `<`가 된다(이중 디코드 없음) —
|
|
53
|
+
* latex·코드 원문에 흔한 `<`·`&`를 정확히 되살리는 데 필요한 성질이다.
|
|
54
|
+
*/
|
|
55
|
+
export function decodeEntities(value) {
|
|
56
|
+
if (!value.includes("&"))
|
|
57
|
+
return value;
|
|
58
|
+
return value.replace(/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([a-zA-Z][a-zA-Z0-9]*));/g, (match, decimal, hex, name) => {
|
|
59
|
+
if (decimal)
|
|
60
|
+
return codePointToString(parseInt(decimal, 10), match);
|
|
61
|
+
if (hex)
|
|
62
|
+
return codePointToString(parseInt(hex, 16), match);
|
|
63
|
+
if (name)
|
|
64
|
+
return NAMED_ENTITIES[name] ?? NAMED_ENTITIES[name.toLowerCase()] ?? match;
|
|
65
|
+
return match;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function codePointToString(code, fallback) {
|
|
69
|
+
try {
|
|
70
|
+
return String.fromCodePoint(code);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** 속성 하나: 이름 + (선택) `=` 값(따옴표 3종). `sanitizeAttributes`의 패턴에 값 없는 꼴을 더했다. */
|
|
77
|
+
const ATTR_PATTERN = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`<>]+)))?/g;
|
|
78
|
+
function parseAttributes(region) {
|
|
79
|
+
const attrs = [];
|
|
80
|
+
ATTR_PATTERN.lastIndex = 0;
|
|
81
|
+
let match;
|
|
82
|
+
while ((match = ATTR_PATTERN.exec(region)) !== null) {
|
|
83
|
+
const raw = match[2] ?? match[3] ?? match[4];
|
|
84
|
+
attrs.push({
|
|
85
|
+
name: match[1].toLowerCase(),
|
|
86
|
+
rawName: match[1],
|
|
87
|
+
value: raw === undefined ? null : decodeEntities(raw),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return attrs;
|
|
91
|
+
}
|
|
92
|
+
/** 여는 태그의 `>` 위치 — 따옴표 안의 `>`(`alt="a>b"`)는 끝이 아니다. */
|
|
93
|
+
function findTagEnd(html, from) {
|
|
94
|
+
let quote = "";
|
|
95
|
+
for (let index = from; index < html.length; index++) {
|
|
96
|
+
const char = html[index];
|
|
97
|
+
if (quote) {
|
|
98
|
+
if (char === quote)
|
|
99
|
+
quote = "";
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (char === '"' || char === "'") {
|
|
103
|
+
quote = char;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === ">")
|
|
107
|
+
return index;
|
|
108
|
+
}
|
|
109
|
+
return -1;
|
|
110
|
+
}
|
|
111
|
+
export function parseHtml(html) {
|
|
112
|
+
const root = { children: [] };
|
|
113
|
+
const stack = [];
|
|
114
|
+
const lower = html.toLowerCase();
|
|
115
|
+
const top = () => (stack.length ? stack[stack.length - 1].children : root.children);
|
|
116
|
+
const pushText = (raw) => {
|
|
117
|
+
if (raw)
|
|
118
|
+
top().push({ type: "text", raw });
|
|
119
|
+
};
|
|
120
|
+
let index = 0;
|
|
121
|
+
while (index < html.length) {
|
|
122
|
+
const lt = html.indexOf("<", index);
|
|
123
|
+
if (lt === -1) {
|
|
124
|
+
pushText(html.slice(index));
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
if (lt > index)
|
|
128
|
+
pushText(html.slice(index, lt));
|
|
129
|
+
const next = html[lt + 1];
|
|
130
|
+
// 주석 — 닫힘이 없으면 나머지 전체가 주석이다(HTML 파서와 같은 처리).
|
|
131
|
+
if (html.startsWith("<!--", lt)) {
|
|
132
|
+
const close = html.indexOf("-->", lt + 4);
|
|
133
|
+
const end = close === -1 ? html.length : close + 3;
|
|
134
|
+
top().push({ type: "raw", raw: html.slice(lt, end) });
|
|
135
|
+
index = end;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// 독타입·CDATA·PI — 통째로 원문 보존.
|
|
139
|
+
if (next === "!" || next === "?") {
|
|
140
|
+
const gt = html.indexOf(">", lt);
|
|
141
|
+
const end = gt === -1 ? html.length : gt + 1;
|
|
142
|
+
top().push({ type: "raw", raw: html.slice(lt, end) });
|
|
143
|
+
index = end;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// 닫는 태그.
|
|
147
|
+
if (next === "/") {
|
|
148
|
+
const gt = html.indexOf(">", lt);
|
|
149
|
+
if (gt === -1) {
|
|
150
|
+
pushText(html.slice(lt));
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
const rawEnd = html.slice(lt, gt + 1);
|
|
154
|
+
const name = /^<\/\s*([a-zA-Z][^\s/>]*)/.exec(rawEnd)?.[1]?.toLowerCase();
|
|
155
|
+
index = gt + 1;
|
|
156
|
+
if (!name) {
|
|
157
|
+
top().push({ type: "raw", raw: rawEnd });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
let open = -1;
|
|
161
|
+
for (let depth = stack.length - 1; depth >= 0; depth--) {
|
|
162
|
+
if (stack[depth].name === name) {
|
|
163
|
+
open = depth;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (open === -1) {
|
|
168
|
+
// 짝 잃은 닫는 태그 — 원문 그대로 흘려보낸다.
|
|
169
|
+
top().push({ type: "raw", raw: rawEnd });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// 사이에 낀 미닫힘 요소들은 닫는 태그 없이 여기서 같이 닫힌 것으로 본다.
|
|
173
|
+
while (stack.length > open + 1)
|
|
174
|
+
stack.pop();
|
|
175
|
+
stack[open].endTag = rawEnd;
|
|
176
|
+
stack.pop();
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// 여는 태그.
|
|
180
|
+
if (next && /[a-zA-Z]/.test(next)) {
|
|
181
|
+
const gt = findTagEnd(html, lt + 1);
|
|
182
|
+
if (gt === -1) {
|
|
183
|
+
pushText(html.slice(lt));
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
const rawTag = html.slice(lt, gt + 1);
|
|
187
|
+
// 위 /[a-zA-Z]/ 검사로 항상 성립한다.
|
|
188
|
+
const rawName = /^<([a-zA-Z][^\s/>]*)/.exec(rawTag)[1];
|
|
189
|
+
const name = rawName.toLowerCase();
|
|
190
|
+
let region = rawTag.slice(1 + rawName.length, -1);
|
|
191
|
+
const selfClosing = /\/\s*$/.test(region);
|
|
192
|
+
if (selfClosing)
|
|
193
|
+
region = region.replace(/\/\s*$/, "");
|
|
194
|
+
const element = {
|
|
195
|
+
type: "element",
|
|
196
|
+
name,
|
|
197
|
+
rawName,
|
|
198
|
+
attrs: parseAttributes(region),
|
|
199
|
+
rawTag,
|
|
200
|
+
endTag: "",
|
|
201
|
+
selfClosing,
|
|
202
|
+
dirty: false,
|
|
203
|
+
children: [],
|
|
204
|
+
};
|
|
205
|
+
top().push(element);
|
|
206
|
+
index = gt + 1;
|
|
207
|
+
if (selfClosing || VOID_ELEMENTS.has(name))
|
|
208
|
+
continue;
|
|
209
|
+
if (RAW_TEXT_ELEMENTS.has(name)) {
|
|
210
|
+
const close = lower.indexOf(`</${name}`, index);
|
|
211
|
+
if (close === -1) {
|
|
212
|
+
if (index < html.length)
|
|
213
|
+
element.children.push({ type: "raw", raw: html.slice(index) });
|
|
214
|
+
index = html.length;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
if (close > index)
|
|
218
|
+
element.children.push({ type: "raw", raw: html.slice(index, close) });
|
|
219
|
+
const endGt = html.indexOf(">", close);
|
|
220
|
+
const end = endGt === -1 ? html.length : endGt + 1;
|
|
221
|
+
element.endTag = html.slice(close, end);
|
|
222
|
+
index = end;
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
stack.push(element);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// `<` 뒤가 태그 이름이 아니다 — 그냥 글자다(`a < b`).
|
|
230
|
+
pushText(html[lt]);
|
|
231
|
+
index = lt + 1;
|
|
232
|
+
}
|
|
233
|
+
return root;
|
|
234
|
+
}
|
|
235
|
+
export function serializeHtml(root) {
|
|
236
|
+
let out = "";
|
|
237
|
+
const walk = (node) => {
|
|
238
|
+
if (node.type !== "element") {
|
|
239
|
+
out += node.raw;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
out += node.dirty ? buildStartTag(node) : node.rawTag;
|
|
243
|
+
node.children.forEach(walk);
|
|
244
|
+
out += node.endTag;
|
|
245
|
+
};
|
|
246
|
+
root.children.forEach(walk);
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/** `dirty` 요소의 여는 태그 재조립. 값은 큰따옴표로 통일하고 `escapeHtml`로 되감는다. */
|
|
250
|
+
function buildStartTag(element) {
|
|
251
|
+
const attrs = element.attrs
|
|
252
|
+
.map((attr) => attr.value === null ? ` ${attr.rawName}` : ` ${attr.rawName}="${escapeHtml(attr.value)}"`)
|
|
253
|
+
.join("");
|
|
254
|
+
return `<${element.rawName}${attrs}${element.selfClosing ? " /" : ""}>`;
|
|
255
|
+
}
|
|
256
|
+
/** DOM `getAttribute`처럼 — 첫 동명 속성, 값 없는 속성은 `""`, 없으면 `null`. */
|
|
257
|
+
export function getAttribute(element, name) {
|
|
258
|
+
const found = element.attrs.find((attr) => attr.name === name);
|
|
259
|
+
if (!found)
|
|
260
|
+
return null;
|
|
261
|
+
return found.value ?? "";
|
|
262
|
+
}
|
|
263
|
+
export function setAttribute(element, name, value) {
|
|
264
|
+
const found = element.attrs.find((attr) => attr.name === name);
|
|
265
|
+
if (found)
|
|
266
|
+
found.value = value;
|
|
267
|
+
else
|
|
268
|
+
element.attrs.push({ name, rawName: name, value });
|
|
269
|
+
element.dirty = true;
|
|
270
|
+
}
|
|
271
|
+
export function firstElementChild(element) {
|
|
272
|
+
for (const child of element.children) {
|
|
273
|
+
if (child.type === "element")
|
|
274
|
+
return child;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
/** DOM `textContent`처럼 자손 텍스트를 디코드해 잇는다. 주석·raw 조각은 세지 않는다. */
|
|
279
|
+
export function textContent(element) {
|
|
280
|
+
let out = "";
|
|
281
|
+
const walk = (node) => {
|
|
282
|
+
if (node.type === "text")
|
|
283
|
+
out += decodeEntities(node.raw);
|
|
284
|
+
else if (node.type === "element")
|
|
285
|
+
node.children.forEach(walk);
|
|
286
|
+
};
|
|
287
|
+
element.children.forEach(walk);
|
|
288
|
+
return out;
|
|
289
|
+
}
|