@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.
Files changed (147) hide show
  1. package/dist/components/BubbleToolbar.svelte +40 -36
  2. package/dist/components/BubbleToolbar.svelte.d.ts +3 -0
  3. package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -1
  4. package/dist/components/EmojiSuggestionMenu.svelte +291 -0
  5. package/dist/components/EmojiSuggestionMenu.svelte.d.ts +9 -0
  6. package/dist/components/EmojiSuggestionMenu.svelte.d.ts.map +1 -0
  7. package/dist/components/FixedToolbar.svelte +53 -47
  8. package/dist/components/FixedToolbar.svelte.d.ts +3 -0
  9. package/dist/components/FixedToolbar.svelte.d.ts.map +1 -1
  10. package/dist/components/FloatingHelper.svelte +219 -0
  11. package/dist/components/FloatingHelper.svelte.d.ts +14 -0
  12. package/dist/components/FloatingHelper.svelte.d.ts.map +1 -0
  13. package/dist/components/InputModal.svelte +7 -3
  14. package/dist/components/InputModal.svelte.d.ts +3 -0
  15. package/dist/components/InputModal.svelte.d.ts.map +1 -1
  16. package/dist/components/MathModal.svelte +33 -29
  17. package/dist/components/MathModal.svelte.d.ts +3 -0
  18. package/dist/components/MathModal.svelte.d.ts.map +1 -1
  19. package/dist/components/MediaPickerModal.svelte +7 -3
  20. package/dist/components/MediaPickerModal.svelte.d.ts +3 -0
  21. package/dist/components/MediaPickerModal.svelte.d.ts.map +1 -1
  22. package/dist/components/SlashCommandMenu.svelte +88 -72
  23. package/dist/components/SlashCommandMenu.svelte.d.ts +3 -0
  24. package/dist/components/SlashCommandMenu.svelte.d.ts.map +1 -1
  25. package/dist/components/TableBubbleMenu.svelte +41 -33
  26. package/dist/components/TableBubbleMenu.svelte.d.ts +3 -0
  27. package/dist/components/TableBubbleMenu.svelte.d.ts.map +1 -1
  28. package/dist/components/TipTapEditor.svelte +239 -127
  29. package/dist/components/TipTapEditor.svelte.d.ts +7 -0
  30. package/dist/components/TipTapEditor.svelte.d.ts.map +1 -1
  31. package/dist/extensions/CardBlock.d.ts.map +1 -1
  32. package/dist/extensions/CardBlock.js +6 -4
  33. package/dist/extensions/FileAttachment.d.ts.map +1 -1
  34. package/dist/extensions/FileAttachment.js +2 -1
  35. package/dist/extensions/FixedDetails.d.ts.map +1 -1
  36. package/dist/extensions/FixedDetails.js +5 -3
  37. package/dist/extensions/LegacyBlock.d.ts.map +1 -1
  38. package/dist/extensions/LegacyBlock.js +8 -6
  39. package/dist/extensions/Math.d.ts.map +1 -1
  40. package/dist/extensions/Math.js +5 -2
  41. package/dist/extensions/MbusVideo.d.ts.map +1 -1
  42. package/dist/extensions/MbusVideo.js +73 -9
  43. package/dist/extensions/MediaToolbar.d.ts +41 -0
  44. package/dist/extensions/MediaToolbar.d.ts.map +1 -0
  45. package/dist/extensions/MediaToolbar.js +196 -0
  46. package/dist/extensions/MediaToolbar.test.d.ts +2 -0
  47. package/dist/extensions/MediaToolbar.test.d.ts.map +1 -0
  48. package/dist/extensions/MediaToolbar.test.js +231 -0
  49. package/dist/extensions/OrderedListMarker.d.ts +2 -0
  50. package/dist/extensions/OrderedListMarker.d.ts.map +1 -0
  51. package/dist/extensions/OrderedListMarker.js +114 -0
  52. package/dist/extensions/OrderedListMarker.test.d.ts +2 -0
  53. package/dist/extensions/OrderedListMarker.test.d.ts.map +1 -0
  54. package/dist/extensions/OrderedListMarker.test.js +176 -0
  55. package/dist/extensions/PdfBlock.d.ts.map +1 -1
  56. package/dist/extensions/PdfBlock.js +25 -23
  57. package/dist/extensions/ResizableImage.d.ts.map +1 -1
  58. package/dist/extensions/ResizableImage.js +20 -1
  59. package/dist/extensions/TableKeymap.d.ts +16 -0
  60. package/dist/extensions/TableKeymap.d.ts.map +1 -0
  61. package/dist/extensions/TableKeymap.js +97 -0
  62. package/dist/extensions/TableKeymap.test.d.ts +2 -0
  63. package/dist/extensions/TableKeymap.test.d.ts.map +1 -0
  64. package/dist/extensions/TableKeymap.test.js +147 -0
  65. package/dist/extensions/TabsBlock.d.ts.map +1 -1
  66. package/dist/extensions/TabsBlock.js +12 -9
  67. package/dist/extensions/TiptapMidibus.d.ts.map +1 -1
  68. package/dist/extensions/TiptapMidibus.js +60 -6
  69. package/dist/extensions/UploadSkeleton.d.ts +61 -0
  70. package/dist/extensions/UploadSkeleton.d.ts.map +1 -0
  71. package/dist/extensions/UploadSkeleton.js +233 -0
  72. package/dist/extensions/UploadSkeleton.test.d.ts +2 -0
  73. package/dist/extensions/UploadSkeleton.test.d.ts.map +1 -0
  74. package/dist/extensions/UploadSkeleton.test.js +128 -0
  75. package/dist/extensions/VideoEmbed.d.ts +8 -0
  76. package/dist/extensions/VideoEmbed.d.ts.map +1 -1
  77. package/dist/extensions/VideoEmbed.js +226 -45
  78. package/dist/extensions/VideoEmbed.test.js +131 -2
  79. package/dist/i18n/en.d.ts +3 -0
  80. package/dist/i18n/en.d.ts.map +1 -0
  81. package/dist/i18n/en.js +191 -0
  82. package/dist/i18n/es.d.ts +3 -0
  83. package/dist/i18n/es.d.ts.map +1 -0
  84. package/dist/i18n/es.js +191 -0
  85. package/dist/i18n/index.d.ts +43 -0
  86. package/dist/i18n/index.d.ts.map +1 -0
  87. package/dist/i18n/index.js +109 -0
  88. package/dist/i18n/index.test.d.ts +2 -0
  89. package/dist/i18n/index.test.d.ts.map +1 -0
  90. package/dist/i18n/index.test.js +173 -0
  91. package/dist/i18n/ja.d.ts +3 -0
  92. package/dist/i18n/ja.d.ts.map +1 -0
  93. package/dist/i18n/ja.js +191 -0
  94. package/dist/i18n/ko.d.ts +198 -0
  95. package/dist/i18n/ko.d.ts.map +1 -0
  96. package/dist/i18n/ko.js +215 -0
  97. package/dist/i18n/zh-hans.d.ts +3 -0
  98. package/dist/i18n/zh-hans.d.ts.map +1 -0
  99. package/dist/i18n/zh-hans.js +191 -0
  100. package/dist/i18n/zh-hant.d.ts +3 -0
  101. package/dist/i18n/zh-hant.d.ts.map +1 -0
  102. package/dist/i18n/zh-hant.js +192 -0
  103. package/dist/index.d.ts +7 -1
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +13 -1
  106. package/dist/static/highlight.d.ts +13 -0
  107. package/dist/static/highlight.d.ts.map +1 -0
  108. package/dist/static/highlight.js +99 -0
  109. package/dist/static/index.d.ts +33 -0
  110. package/dist/static/index.d.ts.map +1 -0
  111. package/dist/static/index.js +367 -0
  112. package/dist/static/index.test.d.ts +2 -0
  113. package/dist/static/index.test.d.ts.map +1 -0
  114. package/dist/static/index.test.js +194 -0
  115. package/dist/static/tokenizer.d.ts +52 -0
  116. package/dist/static/tokenizer.d.ts.map +1 -0
  117. package/dist/static/tokenizer.js +289 -0
  118. package/dist/styles/editor.css +1 -1
  119. package/dist/utils/emoji-data.d.ts +15 -0
  120. package/dist/utils/emoji-suggestion.d.ts +48 -0
  121. package/dist/utils/emoji-suggestion.d.ts.map +1 -0
  122. package/dist/utils/emoji-suggestion.js +51 -0
  123. package/dist/utils/emoji-suggestion.test.d.ts +2 -0
  124. package/dist/utils/emoji-suggestion.test.d.ts.map +1 -0
  125. package/dist/utils/emoji-suggestion.test.js +117 -0
  126. package/dist/utils/floating-helper.d.ts +60 -0
  127. package/dist/utils/floating-helper.d.ts.map +1 -0
  128. package/dist/utils/floating-helper.js +63 -0
  129. package/dist/utils/floating-helper.test.d.ts +2 -0
  130. package/dist/utils/floating-helper.test.d.ts.map +1 -0
  131. package/dist/utils/floating-helper.test.js +141 -0
  132. package/dist/utils/guard-vscode-paste.d.ts +19 -0
  133. package/dist/utils/guard-vscode-paste.d.ts.map +1 -0
  134. package/dist/utils/guard-vscode-paste.js +46 -0
  135. package/dist/utils/guard-vscode-paste.test.d.ts +2 -0
  136. package/dist/utils/guard-vscode-paste.test.d.ts.map +1 -0
  137. package/dist/utils/guard-vscode-paste.test.js +86 -0
  138. package/dist/utils/media-size.d.ts +29 -0
  139. package/dist/utils/media-size.d.ts.map +1 -0
  140. package/dist/utils/media-size.js +90 -0
  141. package/dist/utils/resize.d.ts +12 -0
  142. package/dist/utils/resize.d.ts.map +1 -1
  143. package/dist/utils/resize.js +5 -5
  144. package/dist/utils/sanitize.d.ts +2 -0
  145. package/dist/utils/sanitize.d.ts.map +1 -1
  146. package/dist/utils/sanitize.js +28 -3
  147. package/package.json +8 -1
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `자동`(언어 미지정)일 때 후보로 삼을 언어. **lms 앱 `AUTO_DETECT_SUBSET`과 같은 값이어야
3
+ * 한다** — 한쪽만 바뀌면 정적 색과 에디터 색이 갈린다. 실측상 후보를 늘릴수록 감지가 급격히
4
+ * 나빠지고(190개 등록 → C++을 `properties`로 판정), `c`는 relevance를 과하게 먹어 Python
5
+ * 코드까지 `c`로 판정해서 뺐다. 상세 실측 기록은 앱 쪽 주석에 있다.
6
+ */
7
+ export declare const AUTO_DETECT_SUBSET: string[];
8
+ /**
9
+ * 언어를 알면 그것으로, 모르거나 등록 안 된 언어면 자동 감지로 — 확장의 `getDecorations`와
10
+ * 같은 갈림(앱 `highlightCodeText`). 결과가 빈 문자열이면 강조할 것이 없다는 뜻이다.
11
+ */
12
+ export declare function highlightCodeToHtml(language: string, text: string): string;
13
+ //# sourceMappingURL=highlight.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"highlight.d.ts","sourceRoot":"","sources":["../../src/static/highlight.ts"],"names":[],"mappings":"AAWA;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,UAA4B,CAAC;AA4C5D;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAM1E"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 코드 블록 정적 하이라이트 — lowlight(hast) 결과를 HTML 문자열로.
3
+ *
4
+ * lms 앱 `src/components/ui/tiptap/code/index.ts`의 `createCodeBlockLowlight`·
5
+ * `highlightCodeText`·`serializeHighlight`와 **동작 등가**여야 한다. 정적으로 미리 칠한
6
+ * 색과 에디터가 서면서 lowlight가 칠하는 색이 갈리면, 바꿔 끼우는 순간 색이 통째로 튄다.
7
+ * 등록 언어 목록도 같은 한 벌(`utils/lowlight.ts`)을 쓴다.
8
+ */
9
+ import { lowlight } from "../utils/lowlight";
10
+ import { escapeHtml } from "../utils/escape-html";
11
+ /**
12
+ * `자동`(언어 미지정)일 때 후보로 삼을 언어. **lms 앱 `AUTO_DETECT_SUBSET`과 같은 값이어야
13
+ * 한다** — 한쪽만 바뀌면 정적 색과 에디터 색이 갈린다. 실측상 후보를 늘릴수록 감지가 급격히
14
+ * 나빠지고(190개 등록 → C++을 `properties`로 판정), `c`는 relevance를 과하게 먹어 Python
15
+ * 코드까지 `c`로 판정해서 뺐다. 상세 실측 기록은 앱 쪽 주석에 있다.
16
+ */
17
+ export const AUTO_DETECT_SUBSET = ["cpp", "java", "python"];
18
+ /** hast 트리에서 강조 토큰(element) 수. 후보가 전부 0점일 때 승자를 고르는 기준이다. */
19
+ function countHighlightTokens(tree) {
20
+ let total = 0;
21
+ const walk = (node) => {
22
+ if (node.type === "element")
23
+ total += 1;
24
+ (node.children ?? []).forEach(walk);
25
+ };
26
+ (tree.children ?? []).forEach(walk);
27
+ return total;
28
+ }
29
+ /**
30
+ * 자동 감지. 후보를 `AUTO_DETECT_SUBSET`으로 좁히고, **후보가 전부 relevance 0이면**
31
+ * (타입 선언도 `#include`도 없는 짧은 조각에서 흔하다) hljs가 승자를 고르지 않아 토큰이
32
+ * 하나도 없는 트리가 돌아온다 — 그때는 같은 후보 안에서 토큰이 가장 많이 잡히는 언어로
33
+ * 칠한다. 앱 `createCodeBlockLowlight`의 `highlightAuto` 래퍼와 같은 갈림이다.
34
+ */
35
+ function autoHighlight(value) {
36
+ const auto = lowlight.highlightAuto(value, { subset: AUTO_DETECT_SUBSET });
37
+ if (auto.data?.language)
38
+ return auto;
39
+ let best = null;
40
+ for (const language of AUTO_DETECT_SUBSET) {
41
+ if (!lowlight.registered(language))
42
+ continue;
43
+ const tree = lowlight.highlight(language, value);
44
+ const count = countHighlightTokens(tree);
45
+ if (!best || count > best.count)
46
+ best = { count, tree };
47
+ }
48
+ return best && best.count > 0 ? best.tree : auto;
49
+ }
50
+ /**
51
+ * 언어를 알면 그것으로, 모르거나 등록 안 된 언어면 자동 감지로 — 확장의 `getDecorations`와
52
+ * 같은 갈림(앱 `highlightCodeText`). 결과가 빈 문자열이면 강조할 것이 없다는 뜻이다.
53
+ */
54
+ export function highlightCodeToHtml(language, text) {
55
+ /* `registered`를 먼저 본다 — 목록을 만들지 않는 조회라 블록마다 불려도 싸다. */
56
+ const known = !!language && (lowlight.registered(language) || lowlight.listLanguages().includes(language));
57
+ const tree = known ? lowlight.highlight(language, text) : autoHighlight(text);
58
+ return serializeHighlight(tree);
59
+ }
60
+ /**
61
+ * hast 트리 → HTML 문자열.
62
+ *
63
+ * ⚠️ **에디터가 만드는 마크업과 같은 모양이어야** 바꿔 낄 때 색이 안 튄다. 에디터 쪽은
64
+ * hast를 ProseMirror 인라인 데코레이션으로 붙이며 트리를 **납작하게 편다** — 조상의
65
+ * 클래스를 자식에 물려주며 잎 텍스트마다 합친 클래스의 `span` 한 겹만 만든다. 중첩을
66
+ * 그대로 살리면 같은 CSS에서도 다른 색이 나오므로 여기서도 똑같이 편다.
67
+ * (앱 `serializeHighlight`와 동일. `hast-util-to-html` 같은 의존을 새로 들이지 않는다 —
68
+ * 여기 나오는 노드는 element와 text 둘뿐이다.)
69
+ */
70
+ function serializeHighlight(tree) {
71
+ /* 확장의 `getHighlightNodes`와 같게 — 옛 hljs 결과는 `value`, lowlight는 `children`이다. */
72
+ const roots = Array.isArray(tree.value)
73
+ ? tree.value
74
+ : (tree.children ?? []);
75
+ let out = "";
76
+ const walk = (node, inherited) => {
77
+ const own = classNamesOf(node);
78
+ const classes = own.length ? [...inherited, ...own] : inherited;
79
+ /* 자식이 있으면 **자기 자신은 그리지 않고** 클래스만 물려준다(= 납작하게 편다). */
80
+ if (node.children) {
81
+ node.children.forEach((child) => walk(child, classes));
82
+ return;
83
+ }
84
+ const text = typeof node.value === "string" ? node.value : "";
85
+ if (!text)
86
+ return;
87
+ out += classes.length
88
+ ? `<span class="${escapeHtml(classes.join(" "))}">${escapeHtml(text)}</span>`
89
+ : escapeHtml(text);
90
+ };
91
+ roots.forEach((node) => walk(node, []));
92
+ return out;
93
+ }
94
+ function classNamesOf(node) {
95
+ const raw = node.properties?.className;
96
+ if (Array.isArray(raw))
97
+ return raw.filter((name) => typeof name === "string");
98
+ return typeof raw === "string" ? [raw] : [];
99
+ }
@@ -0,0 +1,33 @@
1
+ export interface RewriteAssetUrlContext {
2
+ /** 소문자 태그 이름(`img`·`a`·`div`…). */
3
+ tag: string;
4
+ /** 소문자 속성 이름(`src`·`srcset`·`href`·`style`·`background`). */
5
+ attr: string;
6
+ }
7
+ export interface RenderStaticHtmlOptions {
8
+ /**
9
+ * 언어를 못 읽은 코드 블록에 쓸 언어. 확장이 보는 `attrs.language || defaultLanguage`와
10
+ * 같은 자리다. **호출부가 정한다** — 어느 코드블록 확장이 실렸느냐로 값이 갈린다.
11
+ * 패키지 기본 에디터(`TipTapEditor`)와 짝이면 `"cpp"`, 호스트가 자기 코드블록 확장을
12
+ * 실었다면 그 확장의 기본값. 생략하면 `""`(자동 감지).
13
+ */
14
+ defaultLanguage?: string;
15
+ /**
16
+ * 본문 속 자산 URL 치환 훅. `src`·`href`는 값 전체가 URL 하나로, `srcset`은 후보마다,
17
+ * `style`·`background`는 `url(…)` 안쪽마다 **URL 하나씩** 불린다 — 단일 URL을 받는
18
+ * 함수(앱 `assetUrl` 류)를 그대로 꽂으면 다섯 속성이 전부 감당된다. 원본과 같은 값을
19
+ * 돌려주면 그 속성은 원문 그대로 남는다.
20
+ */
21
+ rewriteAssetUrl?: (url: string, context: RewriteAssetUrlContext) => string;
22
+ }
23
+ /**
24
+ * 저장 HTML을 **에디터가 그리는 것과 같아 보이게** 손질해 돌려준다. 순수 함수다 —
25
+ * 입력을 바꾸지 않고, 같은 입력이면 항상 같은 출력이며, 할 일이 없으면 원본 문자열을
26
+ * 그대로 돌려준다(재직렬화본으로 바꾸지 않는다).
27
+ *
28
+ * ⚠️ 입력은 `sanitizeHtml` 을 **이미 거친** HTML 이어야 한다 — 여기서는 살균하지 않고,
29
+ * 손대지 않은 구간은 원문 바이트 그대로 통과시킨다.
30
+ */
31
+ export declare function renderStaticHtml(html: string, options?: RenderStaticHtmlOptions): string;
32
+ export { sanitizeHtml, transformLegacyHtml, stripHtmlToExcerpt } from "../utils/sanitize";
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/static/index.ts"],"names":[],"mappings":"AAiCA,MAAM,WAAW,sBAAsB;IACrC,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,uBAAuB;IACtC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,sBAAsB,KAAK,MAAM,CAAC;CAC5E;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,uBAA4B,GAAG,MAAM,CAwB5F;AAgTD,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,367 @@
1
+ /**
2
+ * SSR-safe 정적 렌더 파이프라인 — 저장 HTML(문자열) → 읽기 전용 표시용 HTML(문자열).
3
+ *
4
+ * DOM·브라우저 전역 없이 동작한다(서버·워커·브라우저 어디서든 같은 입력이면 같은 출력).
5
+ * lms 앱의 DOMParser 기반 `prepareStaticHtml`(`src/components/ui/tiptap/code/index.ts`)과
6
+ * `assetMediaPreviewHtml`(`asset-media-preview.ts`)의 동작 등가가 목표이고, 수식 서버
7
+ * 렌더(KaTeX)만 이 파이프라인이 새로 더한 것이다. 다섯 단계를 **한 번의 파싱**으로 처리한다:
8
+ *
9
+ * 1. 코드 블록에 문법 색 입히기 → `highlightCodeBlocks`
10
+ * 2. 블록 사이 공백 전용 텍스트 노드 버리기 → `stripBlockGapWhitespace`
11
+ * 3. `<math-inline>`·`<math-display>` 렌더 → `renderMathElements`
12
+ * 4. `ol[type]` 마커 클래스 입히기 → `annotateOrderedListMarkers`
13
+ * 5. 본문 속 자산 URL 치환 → `rewriteAssetUrls` (옵션)
14
+ *
15
+ * ⚠️ **배럴(`../index.ts`)을 참조하지 않는다.** 배럴 1행이 `TipTapEditor`를 정적으로
16
+ * 끌어와서, 여기서 그걸 스치기만 해도 읽기 전용 라우트의 에디터 지연 로딩이 통째로
17
+ * 무효가 된다. 필요한 것은 전부 모듈 직접 경로로 가져오고, 이 파일도 같은 이유로
18
+ * `@teriusu/rich-editor/static` 서브패스로만 노출된다.
19
+ */
20
+ import katex from "katex";
21
+ import { escapeHtml } from "../utils/escape-html";
22
+ import { highlightCodeToHtml } from "./highlight";
23
+ import { decodeEntities, firstElementChild, getAttribute, parseHtml, serializeHtml, setAttribute, textContent, } from "./tokenizer";
24
+ /**
25
+ * 저장 HTML을 **에디터가 그리는 것과 같아 보이게** 손질해 돌려준다. 순수 함수다 —
26
+ * 입력을 바꾸지 않고, 같은 입력이면 항상 같은 출력이며, 할 일이 없으면 원본 문자열을
27
+ * 그대로 돌려준다(재직렬화본으로 바꾸지 않는다).
28
+ *
29
+ * ⚠️ 입력은 `sanitizeHtml` 을 **이미 거친** HTML 이어야 한다 — 여기서는 살균하지 않고,
30
+ * 손대지 않은 구간은 원문 바이트 그대로 통과시킨다.
31
+ */
32
+ export function renderStaticHtml(html, options = {}) {
33
+ if (!html)
34
+ return html;
35
+ /*
36
+ * ── 빠른 탈출 ── 할 일이 없으면 파싱조차 하지 않는다. 단계별 필요 조건을 문자열만
37
+ * 보고 먼저 가른다 — 앱 `prepareStaticHtml`의 가드와 같은 식이고(코드 판 유무, 태그
38
+ * 사이 개행 유무), 뒤의 두 조건(수식·URL 치환)만 이 파이프라인이 더한 것이다.
39
+ */
40
+ const hasCodeBlock = /<pre[\s>]/i.test(html);
41
+ const hasTagGapNewline = />\s*[\n\r]\s*</.test(html);
42
+ const hasMath = /<math-(?:inline|display)[\s>]/i.test(html);
43
+ // `\stype` — 공백 뒤의 진짜 `type` 속성만. `data-type=`(하이픈 뒤)은 `\s`에 안 걸린다.
44
+ const hasTypedOl = /<ol[^>]*\stype\s*=/i.test(html);
45
+ const rewrite = options.rewriteAssetUrl;
46
+ const hasAssetAttr = !!rewrite && /\b(?:src|srcset|href|style|background)\s*=/i.test(html);
47
+ if (!hasCodeBlock && !hasTagGapNewline && !hasMath && !hasTypedOl && !hasAssetAttr)
48
+ return html;
49
+ const root = parseHtml(html);
50
+ let touched = false;
51
+ if (hasCodeBlock)
52
+ touched = highlightCodeBlocks(root, options.defaultLanguage ?? "") || touched;
53
+ if (hasTagGapNewline)
54
+ touched = stripBlockGapWhitespace(root) || touched;
55
+ if (hasMath)
56
+ touched = renderMathElements(root) || touched;
57
+ if (hasTypedOl)
58
+ touched = annotateOrderedListMarkers(root) || touched;
59
+ if (rewrite && hasAssetAttr)
60
+ touched = rewriteAssetUrls(root, rewrite) || touched;
61
+ return touched ? serializeHtml(root) : html;
62
+ }
63
+ /** 모든 요소를 문서 순서로 방문한다. 방문 중 바꾼 자식(`raw` 주입)은 더 내려가지 않는다. */
64
+ function walkElements(root, visit) {
65
+ const walk = (node) => {
66
+ if (node.type !== "element")
67
+ return;
68
+ visit(node);
69
+ node.children.forEach(walk);
70
+ };
71
+ root.children.forEach(walk);
72
+ }
73
+ /* ─────────────────────── 순서목록 마커 클래스 (`ol[type]`) ─────────────────────── */
74
+ /**
75
+ * `<ol type="A">` 에 `marker-A` 클래스를 입힌다 — 에디터의 `OrderedListMarker.renderHTML`
76
+ * 이 하는 것과 같은 일을 표시 시점에 하는 것이다. CSS 속성 선택자는 HTML `type` 값을
77
+ * **대소문자 무시**로 매치해서(`[type='a']` 가 `A` 도 잡는다) 속성만으로는 lower/upper 를
78
+ * 못 가른다 — 클래스 선택자는 구분한다(`editor.css` 의 `.tiptap ol.marker-*`).
79
+ * 정올 main 에디터가 저장한 본문은 클래스 없이 `type` 만 있으므로 여기서 채워야
80
+ * `A.` 로 만든 목록이 정적 렌더에서 `a.` 로 내려앉지 않는다. 이미 있으면 안 건드린다.
81
+ */
82
+ function annotateOrderedListMarkers(root) {
83
+ let touched = false;
84
+ walkElements(root, (element) => {
85
+ if (element.name !== "ol")
86
+ return;
87
+ const type = getAttribute(element, "type");
88
+ if (!type)
89
+ return;
90
+ const marker = `marker-${type}`;
91
+ const existing = getAttribute(element, "class");
92
+ if (existing && existing.split(/\s+/).includes(marker))
93
+ return;
94
+ setAttribute(element, "class", existing ? `${existing} ${marker}` : marker);
95
+ touched = true;
96
+ });
97
+ return touched;
98
+ }
99
+ /* ────────────────────────── 1. 코드 블록 하이라이트 ────────────────────────── */
100
+ /**
101
+ * 저장된 HTML은 `<pre><code class="language-cpp">원본</code></pre>`이 전부고, 색은 에디터가
102
+ * 살아 있을 때 lowlight가 그때그때 덧씌운다. 여기서 미리 입혀 정적 렌더가 흑백이었다가
103
+ * 에디터로 바뀌는 순간 색이 튀는 것을 없앤다. 앱 `highlightCodeBlocks`와 동작 등가.
104
+ */
105
+ function highlightCodeBlocks(root, defaultLanguage) {
106
+ let touched = false;
107
+ walkElements(root, (element) => {
108
+ /*
109
+ * ⚠️ `pre > code`가 아니라 `pre`를 본다 — 옛 SyntaxHighlighter 저장본은
110
+ * `<pre class="brush:c++; toolbar:false;">코드</pre>`처럼 `<code>` 자식이 아예 없다.
111
+ */
112
+ if (element.name !== "pre")
113
+ return;
114
+ /*
115
+ * 확장의 `parseHTML`은 `pre`의 **첫 요소 자식**에서 `language-*`를 읽는다.
116
+ * 그 자식이 `code`면 색은 그 안에, 없으면 `pre` 자신에 넣는다.
117
+ * ⚠️ **구조는 손대지 않는다** — `<code>`를 새로 감싸거나 클래스를 고치지 않고
118
+ * `span`만 안에 넣는다(교체 전후 `pre` 높이가 이미 맞아 있다).
119
+ */
120
+ const first = firstElementChild(element);
121
+ const target = first && first.name === "code" ? first : element;
122
+ /* 이미 요소가 섞여 있는 저장본(`<br>`·기존 `span` 등)은 손대지 않는다 — 글자가 어긋날 수 있다. */
123
+ if (firstElementChild(target))
124
+ return;
125
+ const text = textContent(target);
126
+ if (!text)
127
+ return;
128
+ /*
129
+ * ⚠️ `brush:c++` 같은 옛 클래스에서 언어를 추측하지 않는다 — 에디터도 못 읽는 표기라
130
+ * 우리만 읽으면 정적 쪽만 다른 언어로 칠해져 색이 갈린다. 기본 언어로 떨어지는 게 맞다.
131
+ */
132
+ const language = readCodeLanguage(first) || defaultLanguage;
133
+ const inner = highlightCodeToHtml(language, text);
134
+ if (!inner)
135
+ return;
136
+ target.children = [{ type: "raw", raw: inner }];
137
+ touched = true;
138
+ });
139
+ return touched;
140
+ }
141
+ /**
142
+ * `<code class="language-cpp">`에서 언어를 뽑는다. 확장의 `parseHTML`과 같은 규칙이라
143
+ * 에디터가 고르는 언어와 어긋나지 않는다. 요소가 없으면(옛 `brush:` 마크업) 빈 문자열.
144
+ */
145
+ function readCodeLanguage(element) {
146
+ if (!element)
147
+ return "";
148
+ const prefix = "language-";
149
+ const found = (getAttribute(element, "class") ?? "")
150
+ .split(/\s+/)
151
+ .find((name) => name.startsWith(prefix));
152
+ return found ? found.slice(prefix.length) : "";
153
+ }
154
+ /* ────────────────────────── 2. 블록 사이 공백 제거 ────────────────────────── */
155
+ /*
156
+ * ⚠️ 태그 이름표로 블록을 판정한다(`getComputedStyle`이 없는 세계다). 목록은 **살균기를
157
+ * 통과하는 태그**(`utils/sanitize.ts`의 `ALLOWED_TAGS`) 중 블록인 것들 — 살균 허용 목록이
158
+ * 늘면 여기도 같이 봐야 한다(빠진 블록은 앞뒤 빈 줄이 안 지워져 그 항목만 정적 대역이
159
+ * 커진다). 앱 `BLOCK_TAGS`와 같은 목록이다.
160
+ */
161
+ const BLOCK_TAGS = new Set([
162
+ "blockquote",
163
+ "col",
164
+ "colgroup",
165
+ "div",
166
+ "figcaption",
167
+ "figure",
168
+ "h1",
169
+ "h2",
170
+ "h3",
171
+ "h4",
172
+ "h5",
173
+ "h6",
174
+ "hr",
175
+ "li",
176
+ "math-display",
177
+ "ol",
178
+ "p",
179
+ "pre",
180
+ "table",
181
+ "tbody",
182
+ "td",
183
+ "th",
184
+ "thead",
185
+ "tr",
186
+ "ul",
187
+ ]);
188
+ function isBlockElement(node) {
189
+ return !!node && node.type === "element" && BLOCK_TAGS.has(node.name);
190
+ }
191
+ /**
192
+ * 공백 노드가 **블록 사이의 틈**인지. 앞뒤 형제 중 하나라도 블록이면 틈이고, 형제가 아예
193
+ * 없으면 블록 컨테이너 안의 빈 줄이다. 둘 다 아니면 **인라인 사이의 공백**이라 남긴다(`a b`).
194
+ */
195
+ function isBlockGap(siblings, index, parent) {
196
+ const prev = siblings[index - 1];
197
+ const next = siblings[index + 1];
198
+ if (isBlockElement(prev) || isBlockElement(next))
199
+ return true;
200
+ return !prev && !next && isBlockElement(parent);
201
+ }
202
+ /**
203
+ * 저장 HTML에서 **블록 사이의 공백 전용 텍스트 노드**를 지운다 — ProseMirror 파서가
204
+ * 콘텐츠를 물 때 하는 일과 같다. 저장본은 블록 태그 사이마다 `"\n\n"` 텍스트 노드를 갖고
205
+ * 있는데, `.tiptap.ProseMirror`의 `white-space: break-spaces` 아래서 그 개행이 진짜 빈
206
+ * 줄로 그려지고 인접 블록의 마진 상쇄까지 막는다(실측 수치는 앱 쪽 주석). 앱
207
+ * `stripBlockGapWhitespace`와 동작 등가:
208
+ *
209
+ * ⚠️ **정규식(`>\s+<`)으로 지우지 않는다** — `<p><strong>a</strong> <em>b</em></p>`처럼
210
+ * 인라인 사이의 의미 있는 공백까지 지워 `a b`가 `ab`로 붙는다. 그래서 트리를 보고
211
+ * 형제가 무엇인지로 가른다.
212
+ * ⚠️ **`pre`·`code` 안은 통째로 건너뛴다** — 거기서는 개행과 들여쓰기가 내용이다.
213
+ */
214
+ function stripBlockGapWhitespace(root) {
215
+ let touched = false;
216
+ const visit = (container) => {
217
+ if ("name" in container && (container.name === "pre" || container.name === "code"))
218
+ return;
219
+ const children = container.children;
220
+ for (const child of children) {
221
+ if (child.type === "element")
222
+ visit(child);
223
+ }
224
+ const keep = children.filter((child, index) => {
225
+ if (child.type !== "text")
226
+ return true;
227
+ /* 글자가 하나라도 있으면 내용이다. ⚠️ 디코드해서 본다 — `&nbsp;`(U+00A0)도 `\s`다. */
228
+ if (/\S/.test(decodeEntities(child.raw)))
229
+ return true;
230
+ return !isBlockGap(children, index, "name" in container ? container : null);
231
+ });
232
+ if (keep.length !== children.length) {
233
+ container.children = keep;
234
+ touched = true;
235
+ }
236
+ };
237
+ visit(root);
238
+ return touched;
239
+ }
240
+ /* ────────────────────────── 3. 수식 서버 렌더 ────────────────────────── */
241
+ /**
242
+ * `<math-inline>`·`<math-display>`를 KaTeX 렌더로 채운다 — 앱의 정적 렌더에는 없는
243
+ * **신규** 단계다(거기선 NodeView가 클라이언트에서 그린다).
244
+ *
245
+ * 저장 HTML에는 latex가 **자식 텍스트로만** 남는다(`extensions/Math.ts`의 `renderHTML` —
246
+ * KaTeX 결과는 저장하지 않는다). 바깥 태그는 속성째 보존하고 안만 바꾼다 — NodeView도
247
+ * 같은 태그(`class="math-node"`)를 유지한 채 `innerHTML`만 채우므로 CSS가 같은 자리에
248
+ * 붙는다.
249
+ */
250
+ function renderMathElements(root) {
251
+ let touched = false;
252
+ walkElements(root, (element) => {
253
+ if (element.name !== "math-inline" && element.name !== "math-display")
254
+ return;
255
+ /* 이미 요소가 들어 있으면(렌더 결과 등) 손대지 않는다 — 출력 재투입에도 안전하다. */
256
+ if (firstElementChild(element))
257
+ return;
258
+ const latex = textContent(element).trim();
259
+ /*
260
+ * 빈 수식은 그대로 둔다. NodeView의 `수식 입력` placeholder는 편집 UI라 읽기 전용
261
+ * 정적 렌더에는 넣지 않는다 — 어차피 그릴 내용이 없다.
262
+ */
263
+ if (!latex)
264
+ return;
265
+ element.children = [{ type: "raw", raw: renderKatex(latex, element.name === "math-display") }];
266
+ touched = true;
267
+ });
268
+ return touched;
269
+ }
270
+ /**
271
+ * `extensions/Math.ts`의 `renderKatex`와 **같은 옵션**이어야 한다 — 정적 렌더와 에디터
272
+ * NodeView의 시각 결과가 갈리면 바꿔 끼울 때 수식이 튄다. 실패 폴백만 이스케이프를
273
+ * 더했다(NodeView는 자기 DOM에 넣지만 여기는 문자열에 원문을 주입하면 안 된다).
274
+ */
275
+ function renderKatex(latex, displayMode) {
276
+ try {
277
+ return katex.renderToString(latex, {
278
+ displayMode,
279
+ throwOnError: false,
280
+ strict: false,
281
+ output: "html",
282
+ });
283
+ }
284
+ catch {
285
+ return `<span class="math-error">${escapeHtml(latex)}</span>`;
286
+ }
287
+ }
288
+ /* ────────────────────────── 4. 자산 URL 치환 ────────────────────────── */
289
+ /**
290
+ * 앱 `assetMediaPreviewHtml`이 치환하는 태그·속성 전부: `[src], [srcset], [href], [style],
291
+ * [background]` — 값 파싱 규칙(`srcset` 후보 나누기, CSS `url(…)` 추출)도 앱의
292
+ * `assetSrcset`·`assetCssUrls`와 같다.
293
+ */
294
+ const REWRITE_ATTRS = ["src", "srcset", "href", "style", "background"];
295
+ function rewriteAssetUrls(root, rewrite) {
296
+ let touched = false;
297
+ walkElements(root, (element) => {
298
+ for (const attr of REWRITE_ATTRS) {
299
+ const value = getAttribute(element, attr);
300
+ if (value === null)
301
+ continue;
302
+ const context = { tag: element.name, attr };
303
+ const rewriteOne = (url) => rewrite(url, context);
304
+ let next;
305
+ if (attr === "srcset")
306
+ next = rewriteSrcset(value, rewriteOne);
307
+ else if (attr === "style" || attr === "background")
308
+ next = rewriteCssUrls(value, rewriteOne);
309
+ else
310
+ next = rewriteOne(value);
311
+ if (next !== value) {
312
+ setAttribute(element, attr, next);
313
+ touched = true;
314
+ }
315
+ }
316
+ });
317
+ return touched;
318
+ }
319
+ /**
320
+ * srcset 후보별 치환 — URL은 공백에서 끝나고, data URL 속 쉼표는 후보 구분자가 아니다.
321
+ * 앱 `assetSrcset`과 같은 스캔이다(콜백만 밖에서 받는다).
322
+ */
323
+ function rewriteSrcset(value, rewrite) {
324
+ let result = "";
325
+ let offset = 0;
326
+ while (offset < value.length) {
327
+ const leading = /^[\s,]*/.exec(value.slice(offset))[0];
328
+ result += leading;
329
+ offset += leading.length;
330
+ if (offset === value.length)
331
+ break;
332
+ const token = /^\S+/.exec(value.slice(offset))[0];
333
+ const url = token.replace(/,+$/, "");
334
+ result += rewrite(url) + token.slice(url.length);
335
+ offset += token.length;
336
+ if (url.length !== token.length)
337
+ continue;
338
+ let depth = 0;
339
+ while (offset < value.length) {
340
+ const char = value[offset++];
341
+ result += char;
342
+ if (char === "(")
343
+ depth++;
344
+ if (char === ")")
345
+ depth--;
346
+ if (char === "," && depth === 0)
347
+ break;
348
+ }
349
+ }
350
+ return result;
351
+ }
352
+ /**
353
+ * CSS 값 속 `url(…)` 안쪽 치환. 안 바뀌면 원문 표기 그대로, 바뀌면 `url("…")`로 통일 —
354
+ * 앱 `assetCssUrls`와 같은 규칙이다.
355
+ */
356
+ function rewriteCssUrls(value, rewrite) {
357
+ return value.replace(/url\(\s*(?:"([^"\\]*)"|'([^'\\]*)'|([^)'"\\]*))\s*\)/gi, (match, doubleQuoted, singleQuoted, bare) => {
358
+ const original = (doubleQuoted ?? singleQuoted ?? bare).trim();
359
+ const rewritten = rewrite(original);
360
+ return rewritten === original ? match : `url("${rewritten}")`;
361
+ });
362
+ }
363
+ /*
364
+ * 살균·레거시 변환·요약 재수출 — 정적 렌더와 늘 붙어 다닌다(살균 → 정적 렌더 순으로 쓴다).
365
+ * ⚠️ 배럴이 아니라 **모듈 직접 경로**에서 가져온다 — 파일 머리 주석 참고.
366
+ */
367
+ export { sanitizeHtml, transformLegacyHtml, stripHtmlToExcerpt } from "../utils/sanitize";
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.test.d.ts","sourceRoot":"","sources":["../../src/static/index.test.ts"],"names":[],"mappings":""}