@heyramzi/ui 1.54.2 → 2.0.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/COMPONENTS.md +3 -2
- package/README.md +34 -9
- package/package.json +205 -13
- package/scripts/sync-shadcn.mjs +2 -0
- package/src/ai/generation-progress/GenerationProgress.svelte +12 -12
- package/src/ambient.d.ts +8 -0
- package/src/brand/brand.test.ts +67 -0
- package/src/brand/index.ts +137 -0
- package/src/canvas/README.md +236 -0
- package/src/canvas/card/card-frame.svelte +61 -0
- package/src/canvas/card/card.css +39 -0
- package/src/canvas/card/resize-handles.svelte +119 -0
- package/src/canvas/card/resize.ts +33 -0
- package/src/canvas/chrome/chrome-zoom.svelte +120 -0
- package/src/canvas/chrome/chrome.css +231 -0
- package/src/canvas/chrome/lock-badge.svelte +18 -0
- package/src/canvas/chrome/side-handles.svelte +30 -0
- package/src/canvas/chrome/size-chip.svelte +45 -0
- package/src/canvas/chrome/snap-guides.svelte +69 -0
- package/src/canvas/components/icons/ToolIcon.svelte +30 -0
- package/src/canvas/components/shared/svg/PreviewSvg.svelte +67 -0
- package/src/canvas/components/shared/svg/ShapeOutline.svelte +76 -0
- package/src/canvas/components/shared/svg/StrokeNode.svelte +69 -0
- package/src/canvas/components/shared/svg/TextRule.svelte +37 -0
- package/src/canvas/edges/bend-handle.svelte +76 -0
- package/src/canvas/edges/edge-gradient.svelte +30 -0
- package/src/canvas/edges/edge-path.test.ts +58 -0
- package/src/canvas/edges/edge-path.ts +293 -0
- package/src/canvas/edges/gradient-edge.svelte +43 -0
- package/src/canvas/editor/context-menu.test.ts +65 -0
- package/src/canvas/editor/context-menu.ts +48 -0
- package/src/canvas/editor/dispatch.svelte.test.ts +243 -0
- package/src/canvas/editor/dispatch.svelte.ts +205 -0
- package/src/canvas/editor/editor-context.ts +62 -0
- package/src/canvas/editor/editor-mode.ts +67 -0
- package/src/canvas/editor/frame.ts +98 -0
- package/src/canvas/editor/hand-context.ts +15 -0
- package/src/canvas/editor/history-core.test.ts +239 -0
- package/src/canvas/editor/history-core.ts +247 -0
- package/src/canvas/editor/keymap.test.ts +161 -0
- package/src/canvas/editor/keymap.ts +151 -0
- package/src/canvas/editor/layout-actions.test.ts +156 -0
- package/src/canvas/editor/layout-actions.ts +219 -0
- package/src/canvas/editor/lock.test.ts +68 -0
- package/src/canvas/editor/lock.ts +87 -0
- package/src/canvas/editor/rebase.test.ts +208 -0
- package/src/canvas/editor/rebase.ts +272 -0
- package/src/canvas/editor/style.ts +184 -0
- package/src/canvas/editor/text-field-context.ts +41 -0
- package/src/canvas/editor/z-order.test.ts +75 -0
- package/src/canvas/editor/z-order.ts +87 -0
- package/src/canvas/headless/alignment.test.ts +59 -0
- package/src/canvas/headless/alignment.ts +23 -0
- package/src/canvas/headless/break-apart-find.ts +418 -0
- package/src/canvas/headless/break-apart-pieces.ts +104 -0
- package/src/canvas/headless/break-apart-plan.ts +439 -0
- package/src/canvas/headless/break-apart-style.ts +106 -0
- package/src/canvas/headless/break-apart.ts +244 -0
- package/src/canvas/headless/canvas-cleanup.test.ts +181 -0
- package/src/canvas/headless/canvas-cleanup.ts +102 -0
- package/src/canvas/headless/edge-anchors.test.ts +70 -0
- package/src/canvas/headless/edge-anchors.ts +143 -0
- package/src/canvas/headless/edge-memo.test.ts +58 -0
- package/src/canvas/headless/edge-memo.ts +32 -0
- package/src/canvas/headless/edge-serialization.test.ts +55 -0
- package/src/canvas/headless/edge-serialization.ts +32 -0
- package/src/canvas/headless/figure-geometry.ts +293 -0
- package/src/canvas/headless/fit-viewport.test.ts +85 -0
- package/src/canvas/headless/fit-viewport.ts +137 -0
- package/src/canvas/headless/grid.test.ts +34 -0
- package/src/canvas/headless/grid.ts +94 -0
- package/src/canvas/headless/icon-node.test.ts +23 -0
- package/src/canvas/headless/icon-node.ts +66 -0
- package/src/canvas/headless/index.ts +9 -0
- package/src/canvas/headless/linkify.test.ts +23 -0
- package/src/canvas/headless/linkify.ts +25 -0
- package/src/canvas/headless/pen-marks.ts +133 -0
- package/src/canvas/headless/sketch.test.ts +99 -0
- package/src/canvas/headless/sketch.ts +302 -0
- package/src/canvas/headless/snap-guides.test.ts +61 -0
- package/src/canvas/headless/snap-guides.ts +146 -0
- package/src/canvas/headless/svg-figure.test.ts +49 -0
- package/src/canvas/headless/svg-figure.ts +176 -0
- package/src/canvas/headless/table-layout.test.ts +158 -0
- package/src/canvas/headless/table-layout.ts +309 -0
- package/src/canvas/index.ts +35 -0
- package/src/canvas/lod/lod-mode.svelte.ts +30 -0
- package/src/canvas/lod/lod-switch.svelte +140 -0
- package/src/canvas/lod/render-all.svelte.ts +52 -0
- package/src/canvas/ops/arrange.ts +108 -0
- package/src/canvas/ops/ops.test.ts +98 -0
- package/src/canvas/ops/ops.ts +347 -0
- package/src/canvas/pen/PenField.svelte +128 -0
- package/src/canvas/pen/PenField.test.ts +87 -0
- package/src/canvas/pen/PenOverlay.svelte +172 -0
- package/src/canvas/pen/ShapeNode.svelte +64 -0
- package/src/canvas/pen/TextNode.svelte +97 -0
- package/src/canvas/pen/colors.ts +28 -0
- package/src/canvas/pen/eraser.ts +46 -0
- package/src/canvas/pen/geometry.ts +148 -0
- package/src/canvas/pen/host-field.test.svelte +13 -0
- package/src/canvas/pen/index.ts +40 -0
- package/src/canvas/pen/ops.ts +16 -0
- package/src/canvas/pen/pen-session.ts +177 -0
- package/src/canvas/pen/pen.css +149 -0
- package/src/canvas/pen/plugin.ts +139 -0
- package/src/canvas/pen/shapes.ts +104 -0
- package/src/canvas/pen/stroke-path.ts +54 -0
- package/src/canvas/pen/types.ts +96 -0
- package/src/canvas/pen/ui.ts +26 -0
- package/src/canvas/plugin.test.ts +45 -0
- package/src/canvas/plugin.ts +159 -0
- package/src/canvas/screen/GlyphIcon.svelte +25 -0
- package/src/canvas/screen/PartView.svelte +176 -0
- package/src/canvas/screen/ScreenBody.svelte +142 -0
- package/src/canvas/screen/ScreenCard.svelte +32 -0
- package/src/canvas/screen/glyphs/button.svg +1 -0
- package/src/canvas/screen/glyphs/close.svg +1 -0
- package/src/canvas/screen/glyphs/down.svg +1 -0
- package/src/canvas/screen/glyphs/header.svg +1 -0
- package/src/canvas/screen/glyphs/hills.svg +1 -0
- package/src/canvas/screen/glyphs/image.svg +1 -0
- package/src/canvas/screen/glyphs/input.svg +1 -0
- package/src/canvas/screen/glyphs/list.svg +1 -0
- package/src/canvas/screen/glyphs/modal.svg +1 -0
- package/src/canvas/screen/glyphs/plus.svg +1 -0
- package/src/canvas/screen/glyphs/up.svg +1 -0
- package/src/canvas/screen/glyphs.ts +40 -0
- package/src/canvas/screen/labels.ts +41 -0
- package/src/canvas/screen/parts.ts +164 -0
- package/src/canvas/screen/plugin.ts +67 -0
- package/src/canvas/screen/screen.css +593 -0
- package/src/canvas/screen/types.ts +47 -0
- package/src/canvas/screen/ui.ts +13 -0
- package/src/canvas/test-utils.ts +18 -0
- package/src/canvas/types.ts +53 -0
- package/src/canvas/ui/CanvasToolbar.svelte +169 -0
- package/src/canvas/ui/FrameLayer.svelte +116 -0
- package/src/canvas/ui/PenLayer.svelte +39 -0
- package/src/canvas/ui/StylePanel.svelte +268 -0
- package/src/canvas/ui/canvas-grid.svelte +156 -0
- package/src/canvas/ui/canvas-ui.css +350 -0
- package/src/canvas/ui/forward-wheel.ts +29 -0
- package/src/canvas/ui/icons.ts +124 -0
- package/src/canvas/ui/place-panel.ts +80 -0
- package/src/canvas/ui/shortcut-tip.test.ts +16 -0
- package/src/canvas/ui/shortcut-tip.ts +17 -0
- package/src/canvas/ui/tools.ts +146 -0
- package/src/charts/README.md +104 -0
- package/src/charts/ambient.d.ts +4 -0
- package/src/charts/animated-value.css +7 -0
- package/src/charts/animated-value.svelte +110 -0
- package/src/charts/area-chart.css +29 -0
- package/src/charts/area-chart.svelte +204 -0
- package/src/charts/bar-chart.css +119 -0
- package/src/charts/bar-chart.svelte +275 -0
- package/src/charts/charts.css +201 -0
- package/src/charts/crosshair.svelte +35 -0
- package/src/charts/dashboard/chart.ts +5 -0
- package/src/charts/dashboard/dashboard-empty.svelte +55 -0
- package/src/charts/dashboard/dashboard-section.svelte +58 -0
- package/src/charts/dashboard/data-panel.svelte +81 -0
- package/src/charts/dashboard/data-sort-head.svelte +102 -0
- package/src/charts/dashboard/data-table.svelte +278 -0
- package/src/charts/dashboard/data-table.ts +26 -0
- package/src/charts/dashboard/index.ts +25 -0
- package/src/charts/dashboard/panel-grid.svelte +43 -0
- package/src/charts/dashboard/period-filter.svelte +53 -0
- package/src/charts/dashboard/rating-chip.svelte +35 -0
- package/src/charts/dashboard/stat-band.svelte +47 -0
- package/src/charts/dashboard/stat-tile.svelte +176 -0
- package/src/charts/dashboard/windows.ts +8 -0
- package/src/charts/donut-chart.css +56 -0
- package/src/charts/donut-chart.svelte +158 -0
- package/src/charts/edge-fade.svelte +43 -0
- package/src/charts/edge-fade.ts +16 -0
- package/src/charts/figure.css +100 -0
- package/src/charts/figure.svelte +122 -0
- package/src/charts/gauge-chart.css +94 -0
- package/src/charts/gauge-chart.svelte +302 -0
- package/src/charts/hover-band.test.ts +30 -0
- package/src/charts/hover-band.ts +21 -0
- package/src/charts/hover-focus.svelte +85 -0
- package/src/charts/index.ts +31 -0
- package/src/charts/legend.css +136 -0
- package/src/charts/legend.svelte +112 -0
- package/src/charts/line-chart.css +26 -0
- package/src/charts/line-chart.svelte +256 -0
- package/src/charts/motion.ts +19 -0
- package/src/charts/number-chart.css +179 -0
- package/src/charts/number-chart.svelte +187 -0
- package/src/charts/primitives/chart-container.svelte +57 -0
- package/src/charts/primitives/chart-style.svelte +36 -0
- package/src/charts/primitives/chart-tooltip.svelte +161 -0
- package/src/charts/primitives/chart-utils.ts +54 -0
- package/src/charts/primitives/index.ts +11 -0
- package/src/charts/reference-line.css +18 -0
- package/src/charts/reference-line.svelte +31 -0
- package/src/charts/reveal.svelte +61 -0
- package/src/charts/ring-chart.css +104 -0
- package/src/charts/ring-chart.svelte +166 -0
- package/src/charts/sparkline.css +45 -0
- package/src/charts/sparkline.svelte +141 -0
- package/src/charts/stat-card.css +105 -0
- package/src/charts/stat-card.svelte +267 -0
- package/src/charts/time-series-chart.css +28 -0
- package/src/charts/time-series-chart.svelte +179 -0
- package/src/charts/trend-pill.css +38 -0
- package/src/charts/trend-pill.svelte +48 -0
- package/src/components/copy/copy-button.svelte +1 -1
- package/src/components/docs/doc-page.svelte +1 -1
- package/src/components/docs/on-this-page.svelte +2 -2
- package/src/components/list-page/empty-state.svelte +1 -1
- package/src/components/list-page/filter-popover.svelte +1 -1
- package/src/components/list-page/section-label.svelte +1 -1
- package/src/components/segmented/segment.svelte +4 -4
- package/src/components/theme/theme-provider.svelte +6 -4
- package/src/components/toolbar/index.ts +5 -0
- package/src/components/toolbar/toolbar.svelte +168 -0
- package/src/components/toolbar/toolbar.test.ts +66 -0
- package/src/editor/README.md +58 -0
- package/src/editor/ambient.d.ts +1 -0
- package/src/editor/block-id.ts +77 -0
- package/src/editor/block-ops.test.ts +237 -0
- package/src/editor/block-ops.ts +157 -0
- package/src/editor/content.css +418 -0
- package/src/editor/editor-toolbar.svelte +303 -0
- package/src/editor/editor.svelte +469 -0
- package/src/editor/extensions.ts +145 -0
- package/src/editor/image-toolbar.svelte +123 -0
- package/src/editor/index.ts +17 -0
- package/src/editor/inline-editor.svelte +261 -0
- package/src/editor/markdown-escaping.test.ts +158 -0
- package/src/editor/markdown-marks.test.ts +89 -0
- package/src/editor/markdown-test-helpers.ts +13 -0
- package/src/editor/markdown-youtube.test.ts +93 -0
- package/src/editor/markdown.test.ts +250 -0
- package/src/editor/markdown.ts +511 -0
- package/src/editor/renderer.svelte +92 -0
- package/src/editor/serializer.test.ts +133 -0
- package/src/editor/serializer.ts +370 -0
- package/src/editor/slash-items.ts +115 -0
- package/src/editor/slash-menu.svelte +159 -0
- package/src/editor/youtube-url.ts +35 -0
- package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +13 -13
- package/src/gallery.test.ts +20 -1
- package/src/lib/result.ts +19 -0
- package/src/onboarding/README.md +120 -0
- package/src/onboarding/define-tours.test.ts +50 -0
- package/src/onboarding/define-tours.ts +31 -0
- package/src/onboarding/demo.svelte +86 -0
- package/src/onboarding/index.ts +12 -0
- package/src/onboarding/onboarding-provider.svelte +120 -0
- package/src/onboarding/onboarding.svelte.ts +24 -0
- package/src/onboarding/pick.test.ts +79 -0
- package/src/onboarding/pick.ts +36 -0
- package/src/onboarding/stores.test.ts +103 -0
- package/src/onboarding/stores.ts +58 -0
- package/src/onboarding/tour-card.svelte +70 -0
- package/src/onboarding/tour-runner.svelte +197 -0
- package/src/onboarding/types.ts +34 -0
- package/src/onboarding/wait.ts +29 -0
- package/src/shadcn/command/command-input.svelte +1 -1
- package/src/slides/Controls.svelte +138 -0
- package/src/slides/Deck.svelte +271 -0
- package/src/slides/Fragment.svelte +71 -0
- package/src/slides/Overview.svelte +113 -0
- package/src/slides/Presenter.svelte +292 -0
- package/src/slides/README.md +89 -0
- package/src/slides/Slide.svelte +179 -0
- package/src/slides/SlideList.svelte +19 -0
- package/src/slides/ambient.d.ts +1 -0
- package/src/slides/bridges.ts +69 -0
- package/src/slides/deck-controller.svelte.ts +292 -0
- package/src/slides/demo-slides.svelte +358 -0
- package/src/slides/demo.svelte +176 -0
- package/src/slides/export/capture.ts +113 -0
- package/src/slides/export/ffmpeg.ts +120 -0
- package/src/slides/export/index.ts +9 -0
- package/src/slides/export/launch.ts +10 -0
- package/src/slides/export/pdf.ts +152 -0
- package/src/slides/export/types.ts +48 -0
- package/src/slides/index.ts +19 -0
- package/src/slides/labels.ts +48 -0
- package/src/slides/layout.ts +51 -0
- package/src/slides/model.ts +47 -0
- package/src/slides/navigation.ts +100 -0
- package/src/slides/theme.css +205 -0
- package/src/slides/types.ts +86 -0
- package/src/tasks/README.md +312 -0
- package/src/tasks/ambient.d.ts +3 -0
- package/src/tasks/board-order.ts +137 -0
- package/src/tasks/dates.ts +31 -0
- package/src/tasks/define-statuses.ts +32 -0
- package/src/tasks/demo.svelte +389 -0
- package/src/tasks/detail/detail-demo.svelte +186 -0
- package/src/tasks/detail/detail-types.ts +54 -0
- package/src/tasks/detail/task-chip.svelte +28 -0
- package/src/tasks/detail/task-detail.svelte +315 -0
- package/src/tasks/detail/task-property.svelte +17 -0
- package/src/tasks/detail/task-thread.svelte +258 -0
- package/src/tasks/gantt/gantt-bar.svelte +243 -0
- package/src/tasks/gantt/gantt-grid.svelte +94 -0
- package/src/tasks/gantt/gantt-group-row.svelte +55 -0
- package/src/tasks/gantt/gantt-header.svelte +71 -0
- package/src/tasks/gantt/gantt-task-row.svelte +186 -0
- package/src/tasks/gantt/gantt.css +22 -0
- package/src/tasks/gantt/gantt.test.ts +77 -0
- package/src/tasks/gantt/logic.test.ts +163 -0
- package/src/tasks/gantt/logic.ts +248 -0
- package/src/tasks/inbox/inbox-card.svelte +283 -0
- package/src/tasks/inbox/inbox-draft.svelte +84 -0
- package/src/tasks/inbox/inbox-logic.ts +55 -0
- package/src/tasks/inbox/inbox-statuses.ts +48 -0
- package/src/tasks/inbox/inbox-trace.svelte +45 -0
- package/src/tasks/inbox/inbox-types.ts +108 -0
- package/src/tasks/inbox/inbox.svelte +203 -0
- package/src/tasks/inbox/index.ts +23 -0
- package/src/tasks/index.ts +85 -0
- package/src/tasks/kanban-board.svelte +561 -0
- package/src/tasks/kanban-column.svelte +117 -0
- package/src/tasks/kanban-drag.ts +38 -0
- package/src/tasks/kanban-types.ts +65 -0
- package/src/tasks/presets/sales.ts +16 -0
- package/src/tasks/record/demo-data.ts +286 -0
- package/src/tasks/record/field-section.svelte +53 -0
- package/src/tasks/record/fields.ts +142 -0
- package/src/tasks/record/format.ts +87 -0
- package/src/tasks/record/gutter.ts +2 -0
- package/src/tasks/record/index.ts +54 -0
- package/src/tasks/record/info-list.svelte +316 -0
- package/src/tasks/record/kinds.ts +42 -0
- package/src/tasks/record/logic.ts +324 -0
- package/src/tasks/record/next-up.svelte +70 -0
- package/src/tasks/record/project-list.svelte +133 -0
- package/src/tasks/record/record-demo.svelte +324 -0
- package/src/tasks/record/record-header.svelte +70 -0
- package/src/tasks/record/record-shell.svelte +138 -0
- package/src/tasks/record/record-stats.svelte +48 -0
- package/src/tasks/record/record-timeline.svelte +231 -0
- package/src/tasks/record/record.test.ts +350 -0
- package/src/tasks/record/steps-rail.svelte +474 -0
- package/src/tasks/record/types.ts +280 -0
- package/src/tasks/record/where-we-are.svelte +121 -0
- package/src/tasks/reorder.ts +19 -0
- package/src/tasks/side-panel-types.ts +16 -0
- package/src/tasks/side-panel.svelte +193 -0
- package/src/tasks/status-badge.svelte +63 -0
- package/src/tasks/status-dot.svelte +228 -0
- package/src/tasks/status-icon.svelte +30 -0
- package/src/tasks/status-types.ts +40 -0
- package/src/tasks/task-gantt.svelte +230 -0
- package/src/tasks/task-list.svelte +470 -0
- package/src/tasks/task-types.ts +33 -0
- package/src/tasks/time/duration.ts +69 -0
- package/src/tasks/time/time-chip.svelte +62 -0
- package/src/tasks/time/time-dot.svelte +58 -0
- package/src/tasks/time/time-ring.svelte +74 -0
- package/src/tasks/time/time-tracker.svelte +565 -0
- package/src/tasks/time/time-types.ts +74 -0
- package/src/tasks/use-kanban-dnd.svelte.ts +173 -0
- package/src/tokens/base.css +5 -5
- package/src/tokens/palette.css +115 -120
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
// markdown-it, not a hand-rolled tokenizer: CommonMark opens underscore
|
|
2
|
+
// emphasis only at word boundaries, so `clic_tempo_v2` is never italic. Pure
|
|
3
|
+
// string transformer, so it is Workers-safe. Italic serialises as `*`.
|
|
4
|
+
|
|
5
|
+
import MarkdownIt from "markdown-it";
|
|
6
|
+
import type { Token } from "markdown-it";
|
|
7
|
+
import { generateBlockId } from "./block-id.js";
|
|
8
|
+
import { YOUTUBE_ID_RE, parseYoutubeUrl } from "./youtube-url.js";
|
|
9
|
+
|
|
10
|
+
// =========================
|
|
11
|
+
// TYPES
|
|
12
|
+
// =========================
|
|
13
|
+
|
|
14
|
+
type TiptapMark = {
|
|
15
|
+
type: string;
|
|
16
|
+
attrs?: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type TiptapNode = {
|
|
20
|
+
type: string;
|
|
21
|
+
attrs?: Record<string, unknown>;
|
|
22
|
+
content?: TiptapNode[];
|
|
23
|
+
marks?: TiptapMark[];
|
|
24
|
+
text?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type TiptapDoc = {
|
|
28
|
+
type: "doc";
|
|
29
|
+
content: TiptapNode[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// =========================
|
|
33
|
+
// PARSER (markdown -> TipTap)
|
|
34
|
+
// =========================
|
|
35
|
+
|
|
36
|
+
// WHY the default preset: commonmark has no strikethrough (s_open/s_close).
|
|
37
|
+
// linkify: false keeps bare URLs as text. breaks: false keeps single newlines soft.
|
|
38
|
+
const mdi = new MarkdownIt({ breaks: false, linkify: false });
|
|
39
|
+
|
|
40
|
+
// WHY: a paragraph holding one image token with a YouTube src is the markdown
|
|
41
|
+
// form of a `youtube` block. It reads markdown-it's own image token, so no
|
|
42
|
+
// custom parser rule.
|
|
43
|
+
function imageTokenToYoutubeNode(tok: Token): TiptapNode | null {
|
|
44
|
+
if (tok.type !== "image") return null;
|
|
45
|
+
const src = String(tok.attrGet("src") ?? "");
|
|
46
|
+
const parsed = parseYoutubeUrl(src);
|
|
47
|
+
if (!parsed) return null;
|
|
48
|
+
return {
|
|
49
|
+
type: "youtube",
|
|
50
|
+
attrs: { videoId: parsed.videoId, title: tok.content ?? "", start: parsed.start, id: null },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function markdownToTiptap(md: string): TiptapDoc {
|
|
55
|
+
const tokens = mdi.parse(md.trim(), {});
|
|
56
|
+
const blocks = parseTokensToBlocks(tokens);
|
|
57
|
+
return { type: "doc", content: blocks };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Walk the flat token array. markdown-it emits block-level open/close pairs
|
|
61
|
+
// at nesting level 0 in the top-level array; inline content is in token.children.
|
|
62
|
+
function parseTokensToBlocks(tokens: Token[]): TiptapNode[] {
|
|
63
|
+
const blocks: TiptapNode[] = [];
|
|
64
|
+
let i = 0;
|
|
65
|
+
while (i < tokens.length) {
|
|
66
|
+
const parse = Object.hasOwn(BLOCK_PARSERS, tokens[i].type)
|
|
67
|
+
? BLOCK_PARSERS[tokens[i].type]
|
|
68
|
+
: null;
|
|
69
|
+
// Unknown or unhandled tokens (html_block, etc.) are skipped.
|
|
70
|
+
if (!parse) {
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const { node, next } = parse(tokens, i);
|
|
75
|
+
blocks.push(stampId(node));
|
|
76
|
+
i = next;
|
|
77
|
+
}
|
|
78
|
+
return blocks;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Parsed = { node: TiptapNode; next: number };
|
|
82
|
+
type BlockParser = (tokens: Token[], i: number) => Parsed;
|
|
83
|
+
|
|
84
|
+
function inlineChildrenAt(tokens: Token[], i: number): Token[] {
|
|
85
|
+
const inlineTok = tokens[i];
|
|
86
|
+
return inlineTok?.type === "inline" ? (inlineTok.children ?? []) : [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function orderedStart(tok: Token): number {
|
|
90
|
+
const startAttr = tok.attrGet("start");
|
|
91
|
+
return startAttr !== null ? parseInt(String(startAttr), 10) : 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// open + inline + close, so the next block starts three tokens on.
|
|
95
|
+
function parseParagraph(tokens: Token[], i: number): Parsed {
|
|
96
|
+
const children = inlineChildrenAt(tokens, i + 1);
|
|
97
|
+
// A lone image with a YouTube src becomes a `youtube` block.
|
|
98
|
+
const youtube = children.length === 1 ? imageTokenToYoutubeNode(children[0]) : null;
|
|
99
|
+
const node = youtube ?? {
|
|
100
|
+
type: "paragraph",
|
|
101
|
+
attrs: { id: null },
|
|
102
|
+
content: parseInlineChildren(children),
|
|
103
|
+
};
|
|
104
|
+
return { node, next: i + 3 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseHeading(tokens: Token[], i: number): Parsed {
|
|
108
|
+
const level = headingLevel(tokens[i].markup);
|
|
109
|
+
const content = parseInlineChildren(inlineChildrenAt(tokens, i + 1));
|
|
110
|
+
return { node: { type: "heading", attrs: { level, id: null }, content }, next: i + 3 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseFence(tokens: Token[], i: number): Parsed {
|
|
114
|
+
const tok = tokens[i];
|
|
115
|
+
const lang = tok.info.trim() || null;
|
|
116
|
+
const code = tok.content.endsWith("\n") ? tok.content.slice(0, -1) : tok.content;
|
|
117
|
+
const node = {
|
|
118
|
+
type: "codeBlock",
|
|
119
|
+
attrs: { language: lang, id: null },
|
|
120
|
+
content: code ? [{ type: "text", text: code }] : [],
|
|
121
|
+
};
|
|
122
|
+
return { node, next: i + 1 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseBlockquote(tokens: Token[], i: number): Parsed {
|
|
126
|
+
const { inner, next } = collectUntilClose(tokens, i + 1, "blockquote");
|
|
127
|
+
const innerBlocks = parseTokensToBlocks(inner).map(stripId);
|
|
128
|
+
const content = innerBlocks.length ? innerBlocks : [{ type: "paragraph", content: [] }];
|
|
129
|
+
// next is the blockquote_close, which is skipped.
|
|
130
|
+
return { node: { type: "blockquote", attrs: { id: null }, content }, next: next + 1 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseBulletList(tokens: Token[], i: number): Parsed {
|
|
134
|
+
const { inner, next } = collectUntilClose(tokens, i + 1, "bullet_list");
|
|
135
|
+
const node = { type: "bulletList", attrs: { id: null }, content: parseListItems(inner) };
|
|
136
|
+
return { node, next: next + 1 };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseOrderedList(tokens: Token[], i: number): Parsed {
|
|
140
|
+
const start = orderedStart(tokens[i]);
|
|
141
|
+
const { inner, next } = collectUntilClose(tokens, i + 1, "ordered_list");
|
|
142
|
+
const node = { type: "orderedList", attrs: { id: null, start }, content: parseListItems(inner) };
|
|
143
|
+
return { node, next: next + 1 };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const BLOCK_PARSERS: Record<string, BlockParser> = {
|
|
147
|
+
"paragraph_open": parseParagraph,
|
|
148
|
+
"heading_open": parseHeading,
|
|
149
|
+
hr: (_tokens, i) => ({ node: { type: "horizontalRule", attrs: { id: null } }, next: i + 1 }),
|
|
150
|
+
fence: parseFence,
|
|
151
|
+
"blockquote_open": parseBlockquote,
|
|
152
|
+
"bullet_list_open": parseBulletList,
|
|
153
|
+
"ordered_list_open": parseOrderedList,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Collect tokens between the current position and the matching _close token.
|
|
157
|
+
// Returns the inner tokens (excluding open/close) and the index of the close token.
|
|
158
|
+
function collectUntilClose(
|
|
159
|
+
tokens: Token[],
|
|
160
|
+
start: number,
|
|
161
|
+
type: string,
|
|
162
|
+
): { inner: Token[]; next: number } {
|
|
163
|
+
const openType = `${type}_open`;
|
|
164
|
+
const closeType = `${type}_close`;
|
|
165
|
+
let depth = 1;
|
|
166
|
+
let i = start;
|
|
167
|
+
|
|
168
|
+
while (i < tokens.length) {
|
|
169
|
+
if (tokens[i].type === openType) depth++;
|
|
170
|
+
else if (tokens[i].type === closeType) {
|
|
171
|
+
depth--;
|
|
172
|
+
if (depth === 0) {
|
|
173
|
+
return { inner: tokens.slice(start, i), next: i };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Unclosed block: return everything remaining.
|
|
180
|
+
return { inner: tokens.slice(start), next: i };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Parse list_item_open ... list_item_close pairs into listItem nodes.
|
|
184
|
+
function parseListItems(tokens: Token[]): TiptapNode[] {
|
|
185
|
+
const items: TiptapNode[] = [];
|
|
186
|
+
let i = 0;
|
|
187
|
+
|
|
188
|
+
while (i < tokens.length) {
|
|
189
|
+
if (tokens[i].type === "list_item_open") {
|
|
190
|
+
const { inner, next } = collectUntilClose(tokens, i + 1, "list_item");
|
|
191
|
+
items.push(parseListItemContent(inner));
|
|
192
|
+
i = next + 1;
|
|
193
|
+
} else {
|
|
194
|
+
i++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return items;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Turn a list_item's inner tokens (paragraphs and nested lists) into a listItem.
|
|
202
|
+
// WHY: nested nodes get no ids; only top-level blocks do.
|
|
203
|
+
function parseListItemContent(tokens: Token[]): TiptapNode {
|
|
204
|
+
const content: TiptapNode[] = [];
|
|
205
|
+
let i = 0;
|
|
206
|
+
|
|
207
|
+
while (i < tokens.length) {
|
|
208
|
+
const tok = tokens[i];
|
|
209
|
+
if (tok.type === "paragraph_open") {
|
|
210
|
+
content.push({
|
|
211
|
+
type: "paragraph",
|
|
212
|
+
content: parseInlineChildren(inlineChildrenAt(tokens, i + 1)),
|
|
213
|
+
});
|
|
214
|
+
i += 3;
|
|
215
|
+
} else if (tok.type === "bullet_list_open" || tok.type === "ordered_list_open") {
|
|
216
|
+
const { node, next } = BLOCK_PARSERS[tok.type](tokens, i);
|
|
217
|
+
content.push(stripId(node));
|
|
218
|
+
i = next;
|
|
219
|
+
} else {
|
|
220
|
+
i++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!content.length) {
|
|
225
|
+
content.push({ type: "paragraph", content: [] });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { type: "listItem", content };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Parse inline token children (flat array from mdi.parse → inline token → children).
|
|
232
|
+
// Maintains a mark stack to accumulate active marks for each text token.
|
|
233
|
+
function parseInlineChildren(tokens: Token[]): TiptapNode[] {
|
|
234
|
+
const nodes: TiptapNode[] = [];
|
|
235
|
+
const markStack: TiptapMark[] = [];
|
|
236
|
+
for (const tok of tokens) {
|
|
237
|
+
const handle = Object.hasOwn(INLINE_HANDLERS, tok.type) ? INLINE_HANDLERS[tok.type] : null;
|
|
238
|
+
// html_inline, entity, image and the rest are dropped. Images are out of scope.
|
|
239
|
+
const node = handle?.(tok, markStack);
|
|
240
|
+
if (node) nodes.push(node);
|
|
241
|
+
}
|
|
242
|
+
return nodes;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Either returns a node to emit, or changes the mark stack and returns nothing.
|
|
246
|
+
type InlineHandler = (tok: Token, markStack: TiptapMark[]) => TiptapNode | void;
|
|
247
|
+
|
|
248
|
+
function textNode(tok: Token, markStack: TiptapMark[]): TiptapNode | void {
|
|
249
|
+
if (!tok.content) return;
|
|
250
|
+
const marks = markStack.length ? [...markStack] : undefined;
|
|
251
|
+
return { type: "text", text: tok.content, ...(marks ? { marks } : {}) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const openMark =
|
|
255
|
+
(type: string): InlineHandler =>
|
|
256
|
+
(_tok, markStack) => {
|
|
257
|
+
markStack.push({ type });
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const closeMark =
|
|
261
|
+
(type: string): InlineHandler =>
|
|
262
|
+
(_tok, markStack) => {
|
|
263
|
+
removeLastMark(markStack, type);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
function openLink(tok: Token, markStack: TiptapMark[]): void {
|
|
267
|
+
const href = tok.attrGet("href") ?? "";
|
|
268
|
+
const title = tok.attrGet("title");
|
|
269
|
+
markStack.push({
|
|
270
|
+
type: "link",
|
|
271
|
+
attrs: { href, target: null, rel: null, ...(title ? { title } : {}) },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const INLINE_HANDLERS: Record<string, InlineHandler> = {
|
|
276
|
+
text: textNode,
|
|
277
|
+
"text_special": textNode,
|
|
278
|
+
// WHY: a softbreak is a newline inside a paragraph, so it reads as a space.
|
|
279
|
+
softbreak: () => ({ type: "text", text: " " }),
|
|
280
|
+
hardbreak: () => ({ type: "hardBreak" }),
|
|
281
|
+
"code_inline": (tok, markStack) => ({
|
|
282
|
+
type: "text",
|
|
283
|
+
text: tok.content,
|
|
284
|
+
marks: [...markStack, { type: "code" }],
|
|
285
|
+
}),
|
|
286
|
+
"strong_open": openMark("bold"),
|
|
287
|
+
"strong_close": closeMark("bold"),
|
|
288
|
+
"em_open": openMark("italic"),
|
|
289
|
+
"em_close": closeMark("italic"),
|
|
290
|
+
"s_open": openMark("strike"),
|
|
291
|
+
"s_close": closeMark("strike"),
|
|
292
|
+
"link_open": openLink,
|
|
293
|
+
"link_close": closeMark("link"),
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
function removeLastMark(stack: TiptapMark[], type: string): void {
|
|
297
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
298
|
+
if (stack[i].type === type) {
|
|
299
|
+
stack.splice(i, 1);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function headingLevel(markup: string): number {
|
|
306
|
+
const n = markup.length;
|
|
307
|
+
return Math.min(Math.max(n, 1), 6);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function stampId(node: TiptapNode): TiptapNode {
|
|
311
|
+
return { ...node, attrs: { ...node.attrs, id: generateBlockId() } };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// WHY: Nested blocks (inside blockquote, list items) must not carry top-level IDs.
|
|
315
|
+
// The ID stamping contract: only direct children of "doc" get an id attribute.
|
|
316
|
+
function stripId(node: TiptapNode): TiptapNode {
|
|
317
|
+
if (!node.attrs || !("id" in node.attrs)) return node;
|
|
318
|
+
const { id: _id, ...rest } = node.attrs;
|
|
319
|
+
if (Object.keys(rest).length > 0) return { ...node, attrs: rest };
|
|
320
|
+
const { attrs: _attrs, ...bare } = node;
|
|
321
|
+
return bare;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// =========================
|
|
325
|
+
// SERIALIZER (TipTap -> markdown)
|
|
326
|
+
// =========================
|
|
327
|
+
|
|
328
|
+
export function tiptapToMarkdown(doc: unknown): string {
|
|
329
|
+
const d = doc as TiptapDoc;
|
|
330
|
+
if (!d || d.type !== "doc" || !Array.isArray(d.content)) return "";
|
|
331
|
+
return d.content
|
|
332
|
+
.map((node) => nodeToMd(node, 0))
|
|
333
|
+
.filter(Boolean)
|
|
334
|
+
.join("\n\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
type MdWriter = (node: TiptapNode, listDepth: number) => string;
|
|
338
|
+
|
|
339
|
+
function numAttr(node: TiptapNode, key: string): number | undefined {
|
|
340
|
+
const value = node.attrs?.[key];
|
|
341
|
+
return typeof value === "number" ? value : undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function strAttr(node: TiptapNode, key: string): string | undefined {
|
|
345
|
+
const value = node.attrs?.[key];
|
|
346
|
+
return typeof value === "string" ? value : undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function childrenToMd(node: TiptapNode, listDepth: number): string {
|
|
350
|
+
return (node.content ?? [])
|
|
351
|
+
.map((child) => nodeToMd(child, listDepth))
|
|
352
|
+
.filter(Boolean)
|
|
353
|
+
.join("\n\n");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function headingToMd(node: TiptapNode): string {
|
|
357
|
+
const level = numAttr(node, "level") ?? 1;
|
|
358
|
+
const prefix = "#".repeat(Math.min(Math.max(level, 1), 6));
|
|
359
|
+
return `${prefix} ${inlineToMd(node.content ?? [])}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function orderedListToMd(node: TiptapNode, listDepth: number): string {
|
|
363
|
+
const start = numAttr(node, "start") ?? 1;
|
|
364
|
+
return (node.content ?? [])
|
|
365
|
+
.map((item, i) => listItemToMd(item, `${start + i}.`, listDepth))
|
|
366
|
+
.join("\n");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function blockquoteToMd(node: TiptapNode): string {
|
|
370
|
+
return childrenToMd(node, 0)
|
|
371
|
+
.split("\n")
|
|
372
|
+
.map((line) => `> ${line}`)
|
|
373
|
+
.join("\n");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function codeBlockToMd(node: TiptapNode): string {
|
|
377
|
+
const lang = strAttr(node, "language") ?? "";
|
|
378
|
+
return `\`\`\`${lang}\n${extractPlainText(node.content ?? [])}\n\`\`\``;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// WHY: the contract's markdown form is an image alone in its paragraph, so this
|
|
382
|
+
// writes back ``.
|
|
383
|
+
function youtubeToMd(node: TiptapNode): string {
|
|
384
|
+
const videoId = strAttr(node, "videoId") ?? "";
|
|
385
|
+
if (!YOUTUBE_ID_RE.test(videoId)) return "";
|
|
386
|
+
const title = strAttr(node, "title") ?? "";
|
|
387
|
+
const rawStart = numAttr(node, "start") ?? 0;
|
|
388
|
+
const start = rawStart > 0 ? rawStart : 0;
|
|
389
|
+
const url = `https://www.youtube.com/watch?v=${videoId}${start ? `&t=${start}` : ""}`;
|
|
390
|
+
return ``;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const dropped: MdWriter = () => "";
|
|
394
|
+
|
|
395
|
+
const MD_WRITERS: Record<string, MdWriter> = {
|
|
396
|
+
paragraph: (node) => inlineToMd(node.content ?? []),
|
|
397
|
+
heading: headingToMd,
|
|
398
|
+
bulletList: (node, listDepth) =>
|
|
399
|
+
(node.content ?? []).map((item) => listItemToMd(item, "-", listDepth)).join("\n"),
|
|
400
|
+
orderedList: orderedListToMd,
|
|
401
|
+
// WHY: a listItem outside a list shouldn't happen, but its content still renders.
|
|
402
|
+
listItem: (node, listDepth) => listItemContentsToMd(node, listDepth),
|
|
403
|
+
blockquote: blockquoteToMd,
|
|
404
|
+
codeBlock: codeBlockToMd,
|
|
405
|
+
horizontalRule: () => "---",
|
|
406
|
+
hardBreak: () => " \n",
|
|
407
|
+
youtube: youtubeToMd,
|
|
408
|
+
// WHY: images, cmsBlock and tables are out of scope for the notes bridge.
|
|
409
|
+
image: dropped,
|
|
410
|
+
cmsBlock: dropped,
|
|
411
|
+
table: dropped,
|
|
412
|
+
tableRow: dropped,
|
|
413
|
+
tableCell: dropped,
|
|
414
|
+
tableHeader: dropped,
|
|
415
|
+
text: (node) => inlineTextToMd(node),
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
function nodeToMd(node: TiptapNode, listDepth: number): string {
|
|
419
|
+
if (Object.hasOwn(MD_WRITERS, node.type)) return MD_WRITERS[node.type](node, listDepth);
|
|
420
|
+
return childrenToMd(node, listDepth);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function listItemToMd(item: TiptapNode, bullet: string, depth: number): string {
|
|
424
|
+
const indent = " ".repeat(depth);
|
|
425
|
+
const content = listItemContentsToMd(item, depth + 1);
|
|
426
|
+
const lines = content.split("\n");
|
|
427
|
+
if (!lines.length) return `${indent}${bullet} `;
|
|
428
|
+
const first = `${indent}${bullet} ${lines[0]}`;
|
|
429
|
+
const rest = lines
|
|
430
|
+
.slice(1)
|
|
431
|
+
.map((l) => (l.trim() ? `${indent} ${l}` : l))
|
|
432
|
+
.join("\n");
|
|
433
|
+
return rest ? `${first}\n${rest}` : first;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function listItemContentsToMd(item: TiptapNode, depth: number): string {
|
|
437
|
+
const parts: string[] = [];
|
|
438
|
+
for (const child of item.content ?? []) {
|
|
439
|
+
if (child.type === "paragraph") {
|
|
440
|
+
parts.push(inlineToMd(child.content ?? []));
|
|
441
|
+
} else if (child.type === "bulletList" || child.type === "orderedList") {
|
|
442
|
+
parts.push(nodeToMd(child, depth));
|
|
443
|
+
} else {
|
|
444
|
+
parts.push(nodeToMd(child, depth));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return parts.join("\n");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function inlineToMd(nodes: TiptapNode[]): string {
|
|
451
|
+
return nodes.map((n) => inlineTextToMd(n)).join("");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// WHY `*` for italic, never `_`: underscore emphasis has CommonMark word-boundary
|
|
455
|
+
// rules, so `_` could break round-trips next to identifiers.
|
|
456
|
+
const MARK_WRAPS: Record<string, (text: string) => string> = {
|
|
457
|
+
bold: (text) => `**${text}**`,
|
|
458
|
+
italic: (text) => `*${text}*`,
|
|
459
|
+
strike: (text) => `~~${text}~~`,
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// A code span keeps its raw text verbatim and replaces whatever was wrapped before it.
|
|
463
|
+
function applyMark(text: string, mark: TiptapMark, raw: string): string {
|
|
464
|
+
if (mark.type === "code") return `\`${raw}\``;
|
|
465
|
+
return Object.hasOwn(MARK_WRAPS, mark.type) ? MARK_WRAPS[mark.type](text) : text;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function inlineTextToMd(node: TiptapNode): string {
|
|
469
|
+
if (node.type === "hardBreak") return " \n";
|
|
470
|
+
if (node.type !== "text") return "";
|
|
471
|
+
|
|
472
|
+
// WHY: plain text is escaped so literal *, _, `, [ and \ never read as markup.
|
|
473
|
+
const escaped = escapeMdText(node.text ?? "");
|
|
474
|
+
if (!node.marks?.length) return escaped;
|
|
475
|
+
|
|
476
|
+
// WHY: the link wraps outermost to make [text](url), so it goes last.
|
|
477
|
+
const linkMark = node.marks.find((m) => m.type === "link");
|
|
478
|
+
const text = node.marks
|
|
479
|
+
.filter((m) => m.type !== "link")
|
|
480
|
+
.reduce((acc, mark) => applyMark(acc, mark, node.text ?? ""), escaped);
|
|
481
|
+
if (!linkMark) return text;
|
|
482
|
+
|
|
483
|
+
const href = typeof linkMark.attrs?.href === "string" ? linkMark.attrs.href : "";
|
|
484
|
+
// WHY: link text is already escaped and the href never is. A code mark already
|
|
485
|
+
// swapped in its raw text, so text is used as-is.
|
|
486
|
+
return `[${text}](${href})`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// WHY: Escape characters that markdown parsers treat as markup delimiters.
|
|
490
|
+
// Only escapes in plain text; code spans and link hrefs handle their own content.
|
|
491
|
+
function escapeMdText(text: string): string {
|
|
492
|
+
return (
|
|
493
|
+
text
|
|
494
|
+
// Backslash must be first to avoid double-escaping.
|
|
495
|
+
.replace(/\\/g, "\\\\")
|
|
496
|
+
.replace(/\*/g, "\\*")
|
|
497
|
+
.replace(/_/g, "\\_")
|
|
498
|
+
.replace(/`/g, "\\`")
|
|
499
|
+
.replace(/\[/g, "\\[")
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function extractPlainText(nodes: TiptapNode[]): string {
|
|
504
|
+
return nodes
|
|
505
|
+
.map((n) => {
|
|
506
|
+
if (n.type === "text") return n.text ?? "";
|
|
507
|
+
if (n.type === "hardBreak") return "\n";
|
|
508
|
+
return extractPlainText(n.content ?? []);
|
|
509
|
+
})
|
|
510
|
+
.join("");
|
|
511
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import "./content.css";
|
|
6
|
+
import { renderTiptapContent } from "./serializer.js";
|
|
7
|
+
|
|
8
|
+
// =========================
|
|
9
|
+
// TYPES
|
|
10
|
+
// =========================
|
|
11
|
+
interface Props {
|
|
12
|
+
content: Record<string, unknown>;
|
|
13
|
+
/** Optional URL prefix to rewrite in rendered HTML (e.g. storage CDN -> local proxy). */
|
|
14
|
+
storagePrefix?: string;
|
|
15
|
+
/** Replacement path for storagePrefix rewrites. */
|
|
16
|
+
storageReplacement?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// =========================
|
|
20
|
+
// PROPS
|
|
21
|
+
// =========================
|
|
22
|
+
let { content, storagePrefix, storageReplacement = "" }: Props = $props();
|
|
23
|
+
|
|
24
|
+
// =========================
|
|
25
|
+
// STATE
|
|
26
|
+
// =========================
|
|
27
|
+
let root = $state<HTMLDivElement | null>(null);
|
|
28
|
+
|
|
29
|
+
// =========================
|
|
30
|
+
// DERIVED
|
|
31
|
+
// =========================
|
|
32
|
+
|
|
33
|
+
// WHY {@html}: the serializer only reads trusted admin CMS JSON, and unlike the
|
|
34
|
+
// old client-only action it renders in SSR, so crawlers see the body. Issue #48.
|
|
35
|
+
let html = $derived.by(() => {
|
|
36
|
+
if (!content) return "";
|
|
37
|
+
let rendered = renderTiptapContent(content);
|
|
38
|
+
if (storagePrefix && storageReplacement !== undefined) {
|
|
39
|
+
rendered = rendered.replaceAll(storagePrefix, storageReplacement);
|
|
40
|
+
}
|
|
41
|
+
return rendered;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// =========================
|
|
45
|
+
// EFFECTS
|
|
46
|
+
// =========================
|
|
47
|
+
|
|
48
|
+
// WHY bound here, not onclick on the div: the facade's real <button> already
|
|
49
|
+
// handles Enter and Space. onclick on the div makes the compiler demand a role.
|
|
50
|
+
$effect(() => {
|
|
51
|
+
const el = root;
|
|
52
|
+
if (!el) return;
|
|
53
|
+
el.addEventListener("click", handleClick);
|
|
54
|
+
return () => el.removeEventListener("click", handleClick);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// =========================
|
|
58
|
+
// FUNCTIONS
|
|
59
|
+
// =========================
|
|
60
|
+
|
|
61
|
+
// WHY: the {@html} facade has no Svelte handlers, so this delegated listener swaps
|
|
62
|
+
// a clicked facade for a youtube-nocookie.com iframe: no cookies, no cost until play.
|
|
63
|
+
function handleClick(event: MouseEvent) {
|
|
64
|
+
const target = event.target as HTMLElement | null;
|
|
65
|
+
const facade = target?.closest<HTMLElement>(".tiptap-youtube");
|
|
66
|
+
if (!facade) return;
|
|
67
|
+
|
|
68
|
+
const videoId = facade.dataset.videoId;
|
|
69
|
+
if (!videoId) return;
|
|
70
|
+
const start = facade.dataset.start || "0";
|
|
71
|
+
const title = facade.querySelector(".tiptap-youtube-title")?.textContent ?? "";
|
|
72
|
+
|
|
73
|
+
const iframe = document.createElement("iframe");
|
|
74
|
+
iframe.src = `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&start=${start}&rel=0`;
|
|
75
|
+
iframe.title = title;
|
|
76
|
+
iframe.setAttribute("frameborder", "0");
|
|
77
|
+
iframe.setAttribute(
|
|
78
|
+
"allow",
|
|
79
|
+
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
|
|
80
|
+
);
|
|
81
|
+
iframe.allowFullscreen = true;
|
|
82
|
+
|
|
83
|
+
facade.replaceChildren(iframe);
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<!-- =========================== -->
|
|
88
|
+
<!-- MARKUP -->
|
|
89
|
+
<!-- =========================== -->
|
|
90
|
+
<div class="tiptap-content" bind:this={root}>
|
|
91
|
+
{@html html}
|
|
92
|
+
</div>
|