wiki-viewer 1.0.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 (119) hide show
  1. package/.next/standalone/.github/workflows/publish.yml +37 -0
  2. package/.next/standalone/DESIGN.md +521 -0
  3. package/.next/standalone/README.md +283 -0
  4. package/.next/standalone/bin/wiki-viewer.js +158 -0
  5. package/.next/standalone/next.config.ts +11 -0
  6. package/.next/standalone/package.json +120 -0
  7. package/.next/standalone/pnpm-lock.yaml +5654 -0
  8. package/.next/standalone/pnpm-workspace.yaml +2 -0
  9. package/.next/standalone/postcss.config.mjs +2 -0
  10. package/.next/standalone/public/logo.svg +11 -0
  11. package/.next/standalone/public/public/logo.svg +11 -0
  12. package/.next/standalone/server.js +44 -0
  13. package/.next/standalone/src/app/api/app-proxy/[...path]/route.ts +247 -0
  14. package/.next/standalone/src/app/api/assets/[...path]/route.ts +70 -0
  15. package/.next/standalone/src/app/api/system/browse/route.ts +78 -0
  16. package/.next/standalone/src/app/api/system/clear-root/route.ts +9 -0
  17. package/.next/standalone/src/app/api/system/config/route.ts +10 -0
  18. package/.next/standalone/src/app/api/system/pins/route.ts +31 -0
  19. package/.next/standalone/src/app/api/system/reveal/route.ts +28 -0
  20. package/.next/standalone/src/app/api/system/root-status/route.ts +9 -0
  21. package/.next/standalone/src/app/api/system/set-root/route.ts +23 -0
  22. package/.next/standalone/src/app/api/upload/[...path]/route.ts +80 -0
  23. package/.next/standalone/src/app/api/wiki/app/route.ts +41 -0
  24. package/.next/standalone/src/app/api/wiki/content/route.ts +60 -0
  25. package/.next/standalone/src/app/api/wiki/file/route.ts +52 -0
  26. package/.next/standalone/src/app/api/wiki/folder/route.ts +26 -0
  27. package/.next/standalone/src/app/api/wiki/move/route.ts +45 -0
  28. package/.next/standalone/src/app/api/wiki/page/route.ts +84 -0
  29. package/.next/standalone/src/app/api/wiki/route.ts +98 -0
  30. package/.next/standalone/src/app/api/wiki/slugs/route.ts +50 -0
  31. package/.next/standalone/src/app/api/wiki/upload/route.ts +87 -0
  32. package/.next/standalone/src/app/api/wiki/watch/route.ts +86 -0
  33. package/.next/standalone/src/app/globals.css +716 -0
  34. package/.next/standalone/src/app/icon.svg +11 -0
  35. package/.next/standalone/src/app/layout.tsx +35 -0
  36. package/.next/standalone/src/app/page.tsx +1509 -0
  37. package/.next/standalone/src/components/confirm-dialog.tsx +56 -0
  38. package/.next/standalone/src/components/dir-picker.tsx +393 -0
  39. package/.next/standalone/src/components/editor/bubble-menu.tsx +286 -0
  40. package/.next/standalone/src/components/editor/callout-extension.ts +36 -0
  41. package/.next/standalone/src/components/editor/csv-viewer.tsx +335 -0
  42. package/.next/standalone/src/components/editor/editor-toolbar.tsx +564 -0
  43. package/.next/standalone/src/components/editor/editor.tsx +633 -0
  44. package/.next/standalone/src/components/editor/extensions/drag-handle.ts +325 -0
  45. package/.next/standalone/src/components/editor/extensions/heading-anchors.ts +55 -0
  46. package/.next/standalone/src/components/editor/extensions/resizable-image.tsx +159 -0
  47. package/.next/standalone/src/components/editor/extensions.ts +148 -0
  48. package/.next/standalone/src/components/editor/file-fallback-viewer.tsx +74 -0
  49. package/.next/standalone/src/components/editor/folder-index.tsx +235 -0
  50. package/.next/standalone/src/components/editor/google-doc-viewer.tsx +73 -0
  51. package/.next/standalone/src/components/editor/image-viewer.tsx +56 -0
  52. package/.next/standalone/src/components/editor/link-popover.tsx +111 -0
  53. package/.next/standalone/src/components/editor/media-popover.tsx +211 -0
  54. package/.next/standalone/src/components/editor/media-viewer.tsx +69 -0
  55. package/.next/standalone/src/components/editor/mermaid-viewer.tsx +285 -0
  56. package/.next/standalone/src/components/editor/node-app-viewer.tsx +269 -0
  57. package/.next/standalone/src/components/editor/notebook-viewer.tsx +370 -0
  58. package/.next/standalone/src/components/editor/office/docx-viewer.tsx +92 -0
  59. package/.next/standalone/src/components/editor/office/office-chrome.tsx +82 -0
  60. package/.next/standalone/src/components/editor/office/pptx-viewer.tsx +103 -0
  61. package/.next/standalone/src/components/editor/office/xlsx-viewer.tsx +116 -0
  62. package/.next/standalone/src/components/editor/pdf-viewer.tsx +31 -0
  63. package/.next/standalone/src/components/editor/slash-commands.tsx +454 -0
  64. package/.next/standalone/src/components/editor/source-viewer.tsx +212 -0
  65. package/.next/standalone/src/components/editor/table-menu.tsx +232 -0
  66. package/.next/standalone/src/components/editor/website-viewer.tsx +74 -0
  67. package/.next/standalone/src/components/editor/wiki-link-create-dialog.tsx +188 -0
  68. package/.next/standalone/src/components/editor/wiki-link-decorator.ts +79 -0
  69. package/.next/standalone/src/components/editor/wiki-link-extension.ts +120 -0
  70. package/.next/standalone/src/components/editor/wiki-link-picker.tsx +250 -0
  71. package/.next/standalone/src/components/layout/viewer-toolbar.tsx +54 -0
  72. package/.next/standalone/src/components/theme-provider.tsx +16 -0
  73. package/.next/standalone/src/components/theme-toggle.tsx +24 -0
  74. package/.next/standalone/src/components/ui/badge.tsx +35 -0
  75. package/.next/standalone/src/components/ui/button.tsx +56 -0
  76. package/.next/standalone/src/components/ui/card.tsx +63 -0
  77. package/.next/standalone/src/components/ui/collapsible.tsx +9 -0
  78. package/.next/standalone/src/components/ui/command.tsx +150 -0
  79. package/.next/standalone/src/components/ui/context-menu.tsx +191 -0
  80. package/.next/standalone/src/components/ui/dialog.tsx +119 -0
  81. package/.next/standalone/src/components/ui/dropdown-menu.tsx +194 -0
  82. package/.next/standalone/src/components/ui/input.tsx +22 -0
  83. package/.next/standalone/src/components/ui/label.tsx +25 -0
  84. package/.next/standalone/src/components/ui/popover.tsx +33 -0
  85. package/.next/standalone/src/components/ui/scroll-area.tsx +47 -0
  86. package/.next/standalone/src/components/ui/select.tsx +156 -0
  87. package/.next/standalone/src/components/ui/separator.tsx +30 -0
  88. package/.next/standalone/src/components/ui/sheet.tsx +140 -0
  89. package/.next/standalone/src/components/ui/skeleton.tsx +15 -0
  90. package/.next/standalone/src/components/ui/switch.tsx +28 -0
  91. package/.next/standalone/src/components/ui/tabs.tsx +54 -0
  92. package/.next/standalone/src/components/ui/textarea.tsx +21 -0
  93. package/.next/standalone/src/components/ui/tip.tsx +23 -0
  94. package/.next/standalone/src/components/ui/toggle-group.tsx +41 -0
  95. package/.next/standalone/src/components/ui/tooltip.tsx +27 -0
  96. package/.next/standalone/src/components/wiki/frontmatter-header.tsx +164 -0
  97. package/.next/standalone/src/lib/app-runner.ts +231 -0
  98. package/.next/standalone/src/lib/cabinets/tree.ts +75 -0
  99. package/.next/standalone/src/lib/config.ts +32 -0
  100. package/.next/standalone/src/lib/embeds/detect.ts +142 -0
  101. package/.next/standalone/src/lib/google/detect.ts +107 -0
  102. package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +84 -0
  103. package/.next/standalone/src/lib/markdown/remark-wikilinks.ts +100 -0
  104. package/.next/standalone/src/lib/markdown/to-html.ts +168 -0
  105. package/.next/standalone/src/lib/markdown/to-markdown.ts +238 -0
  106. package/.next/standalone/src/lib/root-dir.ts +35 -0
  107. package/.next/standalone/src/lib/toast.ts +9 -0
  108. package/.next/standalone/src/lib/utils.ts +6 -0
  109. package/.next/standalone/src/lib/wiki-helpers.ts +36 -0
  110. package/.next/standalone/src/stores/ai-panel-store.ts +23 -0
  111. package/.next/standalone/src/stores/editor-store.ts +244 -0
  112. package/.next/standalone/src/stores/tree-store.ts +52 -0
  113. package/.next/standalone/src/stores/wiki-slugs-store.ts +74 -0
  114. package/.next/standalone/src/types/index.ts +47 -0
  115. package/.next/standalone/tailwind.config.ts +201 -0
  116. package/.next/standalone/tsconfig.json +41 -0
  117. package/README.md +283 -0
  118. package/bin/wiki-viewer.js +158 -0
  119. package/package.json +120 -0
@@ -0,0 +1,232 @@
1
+ "use client";
2
+
3
+ import {
4
+ cellAround,
5
+ isInTable,
6
+ moveTableColumn,
7
+ moveTableRow,
8
+ selectedRect,
9
+ } from "@tiptap/pm/tables";
10
+ import type { Editor } from "@tiptap/react";
11
+ import { BubbleMenu } from "@tiptap/react/menus";
12
+ import {
13
+ ArrowDown,
14
+ ArrowLeft,
15
+ ArrowRight,
16
+ ArrowUp,
17
+ Columns3,
18
+ PanelLeft,
19
+ PanelTop,
20
+ Rows3,
21
+ Table,
22
+ Trash2,
23
+ } from "lucide-react";
24
+ import { cn } from "@/lib/utils";
25
+
26
+ interface TableMenuProps {
27
+ editor: Editor | null;
28
+ }
29
+
30
+ interface TableButtonProps {
31
+ label: string;
32
+ icon: React.ComponentType<{ className?: string }>;
33
+ disabled?: boolean;
34
+ active?: boolean;
35
+ danger?: boolean;
36
+ onAction: () => void;
37
+ }
38
+
39
+ function TableButton({
40
+ label,
41
+ icon: Icon,
42
+ disabled,
43
+ active,
44
+ danger,
45
+ onAction,
46
+ }: TableButtonProps) {
47
+ return (
48
+ <button
49
+ type="button"
50
+ aria-label={label}
51
+ title={label}
52
+ disabled={disabled}
53
+ onMouseDown={(event) => event.preventDefault()}
54
+ onClick={(event) => {
55
+ event.preventDefault();
56
+ onAction();
57
+ }}
58
+ className={cn(
59
+ "h-7 w-7 inline-flex items-center justify-center rounded text-foreground/80 hover:bg-accent transition-colors disabled:pointer-events-none disabled:opacity-35",
60
+ active && "bg-accent text-foreground",
61
+ danger && "text-destructive hover:bg-destructive/10",
62
+ )}
63
+ >
64
+ <Icon className="h-3.5 w-3.5" />
65
+ </button>
66
+ );
67
+ }
68
+
69
+ function Separator() {
70
+ return <div className="mx-1 h-5 w-px bg-border" />;
71
+ }
72
+
73
+ export function TableMenu({ editor }: TableMenuProps) {
74
+ if (!editor) return null;
75
+
76
+ const getRect = () => {
77
+ if (!isInTable(editor.state)) return null;
78
+ try {
79
+ return selectedRect(editor.state);
80
+ } catch {
81
+ return null;
82
+ }
83
+ };
84
+
85
+ const run = (action: () => boolean) => {
86
+ const ok = action();
87
+ if (ok) {
88
+ editor.commands.focus();
89
+ editor.commands.fixTables();
90
+ }
91
+ };
92
+
93
+ const moveRow = (direction: -1 | 1) => {
94
+ const rect = getRect();
95
+ if (!rect) return;
96
+ const from = rect.top;
97
+ const to = from + direction;
98
+ if (to < 0 || to >= rect.map.height) return;
99
+ moveTableRow({ from, to })(editor.state, editor.view.dispatch);
100
+ editor.commands.focus();
101
+ };
102
+
103
+ const moveColumn = (direction: -1 | 1) => {
104
+ const rect = getRect();
105
+ if (!rect) return;
106
+ const from = rect.left;
107
+ const to = from + direction;
108
+ if (to < 0 || to >= rect.map.width) return;
109
+ moveTableColumn({ from, to })(editor.state, editor.view.dispatch);
110
+ editor.commands.focus();
111
+ };
112
+
113
+ const selectCellText = () => {
114
+ const $cell = cellAround(editor.state.selection.$from);
115
+ const cell = $cell?.nodeAfter;
116
+ if (!$cell || !cell) return;
117
+ editor
118
+ .chain()
119
+ .focus()
120
+ .setTextSelection({
121
+ from: $cell.pos + 1,
122
+ to: $cell.pos + cell.nodeSize - 1,
123
+ })
124
+ .run();
125
+ };
126
+
127
+ const rect = getRect();
128
+ const canMoveRowUp = !!rect && rect.top > 0;
129
+ const canMoveRowDown = !!rect && rect.top < rect.map.height - 1;
130
+ const canMoveColumnLeft = !!rect && rect.left > 0;
131
+ const canMoveColumnRight = !!rect && rect.left < rect.map.width - 1;
132
+
133
+ return (
134
+ <BubbleMenu
135
+ editor={editor}
136
+ pluginKey="tableMenu"
137
+ options={{ placement: "top", offset: 10 }}
138
+ shouldShow={({ editor: activeEditor }) => activeEditor.isActive("table")}
139
+ className="flex items-center gap-0.5 rounded-md border border-border bg-popover px-1 py-1 shadow-lg"
140
+ >
141
+ <TableButton
142
+ label="Select cell text"
143
+ icon={Table}
144
+ onAction={selectCellText}
145
+ />
146
+ <Separator />
147
+ <TableButton
148
+ label="Add row above"
149
+ icon={Rows3}
150
+ onAction={() => run(() => editor.chain().focus().addRowBefore().run())}
151
+ />
152
+ <TableButton
153
+ label="Add row below"
154
+ icon={ArrowDown}
155
+ onAction={() => run(() => editor.chain().focus().addRowAfter().run())}
156
+ />
157
+ <TableButton
158
+ label="Move row up"
159
+ icon={ArrowUp}
160
+ disabled={!canMoveRowUp}
161
+ onAction={() => moveRow(-1)}
162
+ />
163
+ <TableButton
164
+ label="Move row down"
165
+ icon={ArrowDown}
166
+ disabled={!canMoveRowDown}
167
+ onAction={() => moveRow(1)}
168
+ />
169
+ <TableButton
170
+ label="Delete row"
171
+ icon={Trash2}
172
+ danger
173
+ onAction={() => run(() => editor.chain().focus().deleteRow().run())}
174
+ />
175
+ <Separator />
176
+ <TableButton
177
+ label="Add column before"
178
+ icon={Columns3}
179
+ onAction={() =>
180
+ run(() => editor.chain().focus().addColumnBefore().run())
181
+ }
182
+ />
183
+ <TableButton
184
+ label="Add column after"
185
+ icon={ArrowRight}
186
+ onAction={() =>
187
+ run(() => editor.chain().focus().addColumnAfter().run())
188
+ }
189
+ />
190
+ <TableButton
191
+ label="Move column left"
192
+ icon={ArrowLeft}
193
+ disabled={!canMoveColumnLeft}
194
+ onAction={() => moveColumn(-1)}
195
+ />
196
+ <TableButton
197
+ label="Move column right"
198
+ icon={ArrowRight}
199
+ disabled={!canMoveColumnRight}
200
+ onAction={() => moveColumn(1)}
201
+ />
202
+ <TableButton
203
+ label="Delete column"
204
+ icon={Trash2}
205
+ danger
206
+ onAction={() => run(() => editor.chain().focus().deleteColumn().run())}
207
+ />
208
+ <Separator />
209
+ <TableButton
210
+ label="Toggle header row"
211
+ icon={PanelTop}
212
+ onAction={() =>
213
+ run(() => editor.chain().focus().toggleHeaderRow().run())
214
+ }
215
+ />
216
+ <TableButton
217
+ label="Toggle header column"
218
+ icon={PanelLeft}
219
+ onAction={() =>
220
+ run(() => editor.chain().focus().toggleHeaderColumn().run())
221
+ }
222
+ />
223
+ <Separator />
224
+ <TableButton
225
+ label="Delete table"
226
+ icon={Trash2}
227
+ danger
228
+ onAction={() => editor.chain().focus().deleteTable().run()}
229
+ />
230
+ </BubbleMenu>
231
+ );
232
+ }
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ import { ArrowLeft, ExternalLink } from "lucide-react";
4
+ import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ interface WebsiteViewerProps {
8
+ path: string;
9
+ title: string;
10
+ /** Override iframe src. Defaults to `/api/assets/{path}/index.html`. */
11
+ src?: string;
12
+ fullscreen?: boolean;
13
+ onExit?: () => void;
14
+ }
15
+
16
+ export function WebsiteViewer({
17
+ path,
18
+ title,
19
+ src,
20
+ fullscreen,
21
+ onExit,
22
+ }: WebsiteViewerProps) {
23
+ const iframeSrc = src ?? `/api/assets/${path}/index.html`;
24
+ const exitButton =
25
+ fullscreen && onExit ? (
26
+ <Button
27
+ variant="ghost"
28
+ size="sm"
29
+ className="h-7 gap-1.5 text-xs"
30
+ onClick={onExit}
31
+ title="Exit app"
32
+ >
33
+ <ArrowLeft className="h-3.5 w-3.5" />
34
+ Exit app
35
+ </Button>
36
+ ) : null;
37
+
38
+ return (
39
+ <div className="flex-1 flex flex-col overflow-hidden">
40
+ <ViewerToolbar
41
+ path={path}
42
+ badge={fullscreen ? "App" : undefined}
43
+ showBreadcrumb={!fullscreen}
44
+ leading={
45
+ fullscreen ? (
46
+ <>
47
+ {exitButton}
48
+ <span className="truncate text-[13px] font-medium text-foreground">
49
+ {title}
50
+ </span>
51
+ </>
52
+ ) : null
53
+ }
54
+ >
55
+ <Button
56
+ variant="ghost"
57
+ size="sm"
58
+ className="h-7 gap-1.5 text-xs"
59
+ onClick={() => window.open(iframeSrc, "_blank")}
60
+ >
61
+ <ExternalLink className="h-3.5 w-3.5" />
62
+ Open in new tab
63
+ </Button>
64
+ </ViewerToolbar>
65
+
66
+ <iframe
67
+ src={iframeSrc}
68
+ className="flex-1 w-full border-0 bg-card"
69
+ title={title}
70
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation"
71
+ />
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,188 @@
1
+ "use client";
2
+
3
+ import type React from "react";
4
+ import { useCallback, useRef, useState } from "react";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ DialogContent,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ Dialog as DialogRoot,
11
+ DialogTitle,
12
+ } from "@/components/ui/dialog";
13
+ import { Input } from "@/components/ui/input";
14
+ import { Label } from "@/components/ui/label";
15
+ import { showError } from "@/lib/toast";
16
+ import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
17
+
18
+ type Dir = "entities" | "concepts" | "comparisons";
19
+
20
+ export interface WikiCreateResult {
21
+ ok: boolean;
22
+ slug: string;
23
+ dir?: Dir;
24
+ }
25
+
26
+ interface DialogState {
27
+ slug: string;
28
+ dir: Dir;
29
+ title: string;
30
+ }
31
+
32
+ function humanizeSlug(slug: string): string {
33
+ return slug
34
+ .split("-")
35
+ .filter(Boolean)
36
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
37
+ .join(" ");
38
+ }
39
+
40
+ const DIRS: Dir[] = ["entities", "concepts", "comparisons"];
41
+
42
+ export function useWikiLinkCreate(): {
43
+ open: (slug: string) => Promise<WikiCreateResult>;
44
+ Dialog: React.ReactNode;
45
+ } {
46
+ const [isOpen, setIsOpen] = useState(false);
47
+ const [dialogState, setDialogState] = useState<DialogState>({
48
+ slug: "",
49
+ dir: "entities",
50
+ title: "",
51
+ });
52
+ const [isSubmitting, setIsSubmitting] = useState(false);
53
+ const resolveRef = useRef<((result: WikiCreateResult) => void) | null>(null);
54
+
55
+ const open = useCallback((slug: string): Promise<WikiCreateResult> => {
56
+ setDialogState({
57
+ slug,
58
+ dir: "entities",
59
+ title: humanizeSlug(slug),
60
+ });
61
+ setIsOpen(true);
62
+ return new Promise<WikiCreateResult>((resolve) => {
63
+ resolveRef.current = resolve;
64
+ });
65
+ }, []);
66
+
67
+ const handleCancel = useCallback(() => {
68
+ setIsOpen(false);
69
+ resolveRef.current?.({ ok: false, slug: dialogState.slug });
70
+ resolveRef.current = null;
71
+ }, [dialogState.slug]);
72
+
73
+ const handleCreate = useCallback(async () => {
74
+ const { slug, dir, title } = dialogState;
75
+ setIsSubmitting(true);
76
+ try {
77
+ const res = await fetch("/api/wiki/page", {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify({
81
+ slug,
82
+ dir,
83
+ title: title.trim() || undefined,
84
+ }),
85
+ });
86
+
87
+ if (res.ok || res.status === 409) {
88
+ // 409 means the page already exists — still a success from our POV
89
+ const store = useWikiSlugsStore.getState();
90
+ store.invalidate();
91
+ void store.load();
92
+ setIsOpen(false);
93
+ resolveRef.current?.({ ok: true, slug, dir });
94
+ resolveRef.current = null;
95
+ } else {
96
+ const data: unknown = await res.json().catch(() => ({}));
97
+ const msg =
98
+ data !== null &&
99
+ typeof data === "object" &&
100
+ "error" in data &&
101
+ typeof (data as { error: unknown }).error === "string"
102
+ ? (data as { error: string }).error
103
+ : "Failed to create page";
104
+ showError(msg);
105
+ }
106
+ } catch {
107
+ showError("Failed to create page");
108
+ } finally {
109
+ setIsSubmitting(false);
110
+ }
111
+ }, [dialogState]);
112
+
113
+ const dialogNode = (
114
+ <DialogRoot
115
+ open={isOpen}
116
+ onOpenChange={(v) => {
117
+ if (!v) handleCancel();
118
+ }}
119
+ >
120
+ <DialogContent>
121
+ <DialogHeader>
122
+ <DialogTitle>Create wiki page</DialogTitle>
123
+ </DialogHeader>
124
+
125
+ <div className="space-y-4 py-2">
126
+ <div className="space-y-1.5">
127
+ <Label htmlFor="wiki-create-slug">Slug</Label>
128
+ <Input
129
+ id="wiki-create-slug"
130
+ value={dialogState.slug}
131
+ readOnly
132
+ className="bg-muted cursor-default select-all"
133
+ />
134
+ </div>
135
+
136
+ <div className="space-y-1.5">
137
+ <Label>Directory</Label>
138
+ <div className="flex gap-4">
139
+ {DIRS.map((d) => (
140
+ <label
141
+ key={d}
142
+ className="flex items-center gap-1.5 text-sm cursor-pointer"
143
+ >
144
+ <input
145
+ type="radio"
146
+ name="wiki-create-dir"
147
+ value={d}
148
+ checked={dialogState.dir === d}
149
+ onChange={() => setDialogState((s) => ({ ...s, dir: d }))}
150
+ className="accent-primary"
151
+ />
152
+ {d}
153
+ </label>
154
+ ))}
155
+ </div>
156
+ </div>
157
+
158
+ <div className="space-y-1.5">
159
+ <Label htmlFor="wiki-create-title">Title (optional)</Label>
160
+ <Input
161
+ id="wiki-create-title"
162
+ value={dialogState.title}
163
+ onChange={(e) =>
164
+ setDialogState((s) => ({ ...s, title: e.target.value }))
165
+ }
166
+ placeholder="Humanized title..."
167
+ />
168
+ </div>
169
+ </div>
170
+
171
+ <DialogFooter>
172
+ <Button
173
+ variant="outline"
174
+ onClick={handleCancel}
175
+ disabled={isSubmitting}
176
+ >
177
+ Cancel
178
+ </Button>
179
+ <Button onClick={() => void handleCreate()} disabled={isSubmitting}>
180
+ {isSubmitting ? "Creating..." : "Create"}
181
+ </Button>
182
+ </DialogFooter>
183
+ </DialogContent>
184
+ </DialogRoot>
185
+ );
186
+
187
+ return { open, Dialog: dialogNode };
188
+ }
@@ -0,0 +1,79 @@
1
+ import type { EditorState, Transaction } from "@tiptap/pm/state";
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state";
3
+ import { Decoration, DecorationSet } from "@tiptap/pm/view";
4
+ import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
5
+
6
+ const WIKI_LINK_DECORATOR_KEY = new PluginKey<DecorationSet>(
7
+ "wikiLinkDecorator",
8
+ );
9
+
10
+ const REFRESH_META = "wikiLinkDecorator:refresh";
11
+
12
+ /**
13
+ * Walk the doc, find every wikiLink mark, and emit an inline decoration
14
+ * carrying data-broken="true" for any slug not in the slug index store.
15
+ *
16
+ * Decorations apply attributes during ProseMirror's own DOM render pass,
17
+ * so they don't trigger the MutationObserver feedback loop that direct
18
+ * setAttribute would.
19
+ */
20
+ function buildDecorations(state: EditorState): DecorationSet {
21
+ const { has } = useWikiSlugsStore.getState();
22
+ const markType = state.schema.marks.wikiLink;
23
+ if (!markType) return DecorationSet.empty;
24
+
25
+ const decorations: Decoration[] = [];
26
+ state.doc.descendants((node, pos) => {
27
+ if (!node.isText) return;
28
+ const mark = node.marks.find((m) => m.type === markType);
29
+ if (!mark) return;
30
+ const slug = mark.attrs.slug as string | undefined;
31
+ if (!slug || has(slug)) return;
32
+ decorations.push(
33
+ Decoration.inline(pos, pos + node.nodeSize, {
34
+ "data-broken": "true",
35
+ }),
36
+ );
37
+ });
38
+
39
+ return DecorationSet.create(state.doc, decorations);
40
+ }
41
+
42
+ /**
43
+ * Plugin that marks wiki-links as broken when their slug is absent from
44
+ * the slug index. Recomputes on doc changes and when the slug store
45
+ * signals a refresh via a meta-only transaction.
46
+ */
47
+ export function wikiLinkDecoratorPlugin(): Plugin {
48
+ return new Plugin<DecorationSet>({
49
+ key: WIKI_LINK_DECORATOR_KEY,
50
+ state: {
51
+ init: (_config, state) => buildDecorations(state),
52
+ apply(tr: Transaction, old: DecorationSet, _oldState, newState) {
53
+ if (tr.getMeta(REFRESH_META)) return buildDecorations(newState);
54
+ if (!tr.docChanged) return old.map(tr.mapping, tr.doc);
55
+ return buildDecorations(newState);
56
+ },
57
+ },
58
+ props: {
59
+ decorations(state) {
60
+ return WIKI_LINK_DECORATOR_KEY.getState(state) ?? DecorationSet.empty;
61
+ },
62
+ },
63
+ view(editorView) {
64
+ // Subscribe to slug-store changes. When the index loads or
65
+ // invalidates, dispatch a meta-only transaction so the plugin
66
+ // state recomputes against the latest snapshot.
67
+ const unsubscribe = useWikiSlugsStore.subscribe(() => {
68
+ const tr = editorView.state.tr.setMeta(REFRESH_META, true);
69
+ editorView.dispatch(tr);
70
+ });
71
+
72
+ return {
73
+ destroy() {
74
+ unsubscribe();
75
+ },
76
+ };
77
+ },
78
+ });
79
+ }
@@ -0,0 +1,120 @@
1
+ import { InputRule, Mark, mergeAttributes } from "@tiptap/core";
2
+ import { wikiLinkDecoratorPlugin } from "./wiki-link-decorator";
3
+
4
+ // Matches: [[slug]] [[slug|alias]] [[slug#anchor]]
5
+ // Groups: 1=slug 2=alias 3=anchor
6
+ const INPUT_RULE_REGEX = /\[\[([a-z0-9-]+)(?:\|([^\]#|]+)|#([a-z0-9-]+))?\]\]$/;
7
+
8
+ declare module "@tiptap/core" {
9
+ interface Commands<ReturnType> {
10
+ wikiLink: {
11
+ /** Apply a wiki-link mark to the current selection. */
12
+ setWikiLink: (attrs: {
13
+ slug: string;
14
+ alias?: string | null;
15
+ anchor?: string | null;
16
+ }) => ReturnType;
17
+ /** Remove the wiki-link mark from the current selection. */
18
+ unsetWikiLink: () => ReturnType;
19
+ };
20
+ }
21
+ }
22
+
23
+ export const WikiLink = Mark.create({
24
+ name: "wikiLink",
25
+ priority: 1000,
26
+ keepOnSplit: false,
27
+ inclusive: false,
28
+
29
+ addAttributes() {
30
+ return {
31
+ slug: {
32
+ default: null,
33
+ parseHTML: (element) => element.getAttribute("data-slug"),
34
+ renderHTML: (attributes) => ({
35
+ "data-slug": attributes.slug,
36
+ }),
37
+ },
38
+ alias: {
39
+ default: null,
40
+ parseHTML: (element) => element.getAttribute("data-alias"),
41
+ renderHTML: (attributes) =>
42
+ attributes.alias != null ? { "data-alias": attributes.alias } : {},
43
+ },
44
+ anchor: {
45
+ default: null,
46
+ parseHTML: (element) => element.getAttribute("data-anchor"),
47
+ renderHTML: (attributes) =>
48
+ attributes.anchor != null ? { "data-anchor": attributes.anchor } : {},
49
+ },
50
+ };
51
+ },
52
+
53
+ parseHTML() {
54
+ return [{ tag: 'a[data-wiki-link="true"]' }];
55
+ },
56
+
57
+ renderHTML({ HTMLAttributes }) {
58
+ const slug = String(HTMLAttributes["data-slug"] ?? "");
59
+ const anchor =
60
+ HTMLAttributes["data-anchor"] != null
61
+ ? String(HTMLAttributes["data-anchor"])
62
+ : null;
63
+ const href = anchor ? `#wiki:${slug}#${anchor}` : `#wiki:${slug}`;
64
+ return [
65
+ "a",
66
+ mergeAttributes(HTMLAttributes, {
67
+ "data-wiki-link": "true",
68
+ href,
69
+ class: "wiki-link",
70
+ }),
71
+ 0,
72
+ ];
73
+ },
74
+
75
+ addInputRules() {
76
+ // markInputRule always uses the last capture group as visible text and
77
+ // cannot produce variable text per form ([[slug]] vs [[slug|alias]] vs
78
+ // [[slug#anchor]]). A raw InputRule is required here.
79
+ return [
80
+ new InputRule({
81
+ find: INPUT_RULE_REGEX,
82
+ handler: ({ state, range, match }) => {
83
+ const slug: string = match[1] ?? "";
84
+ const alias: string | null = match[2] ?? null;
85
+ const anchor: string | null = match[3] ?? null;
86
+ const visibleText = alias ?? (anchor ? `${slug}#${anchor}` : slug);
87
+ const { tr } = state;
88
+ tr.replaceWith(
89
+ range.from,
90
+ range.to,
91
+ state.schema.text(visibleText, [
92
+ this.type.create({ slug, alias, anchor }),
93
+ ]),
94
+ );
95
+ },
96
+ }),
97
+ ];
98
+ },
99
+
100
+ addProseMirrorPlugins() {
101
+ return [wikiLinkDecoratorPlugin()];
102
+ },
103
+
104
+ addCommands() {
105
+ return {
106
+ setWikiLink:
107
+ (attrs) =>
108
+ ({ commands }) =>
109
+ commands.setMark(this.name, {
110
+ slug: attrs.slug,
111
+ alias: attrs.alias ?? null,
112
+ anchor: attrs.anchor ?? null,
113
+ }),
114
+ unsetWikiLink:
115
+ () =>
116
+ ({ commands }) =>
117
+ commands.unsetMark(this.name),
118
+ };
119
+ },
120
+ });