@valentinkolb/cloud 0.4.0 → 0.5.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 (193) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +113 -10
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +0 -2
  49. package/src/services/auth-flows/magic-link.ts +3 -2
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/oauth-tokens.ts +104 -0
  64. package/src/services/postgres.ts +21 -6
  65. package/src/services/providers/local/auth.test.ts +22 -0
  66. package/src/services/providers/local/auth.ts +46 -3
  67. package/src/services/secrets.ts +10 -0
  68. package/src/services/service-account-credentials.test.ts +210 -0
  69. package/src/services/service-account-credentials.ts +715 -0
  70. package/src/services/service-accounts.ts +188 -0
  71. package/src/services/session/index.ts +7 -8
  72. package/src/services/settings/app.ts +4 -20
  73. package/src/services/settings/defaults.ts +64 -22
  74. package/src/services/settings/store.ts +47 -0
  75. package/src/services/weather/forecast.ts +40 -7
  76. package/src/services/webauthn.test.ts +36 -0
  77. package/src/services/webauthn.ts +384 -0
  78. package/src/shared/icons.ts +391 -100
  79. package/src/shared/index.ts +7 -0
  80. package/src/shared/markdown/extensions/code.ts +38 -1
  81. package/src/shared/markdown/extensions/images.ts +39 -3
  82. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  83. package/src/shared/markdown/extensions/mark.ts +48 -0
  84. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  85. package/src/shared/markdown/extensions/tables.ts +79 -58
  86. package/src/shared/markdown/formula.test.ts +1089 -0
  87. package/src/shared/markdown/formula.ts +1187 -0
  88. package/src/shared/markdown/index.ts +76 -2
  89. package/src/shared/mock-cover.ts +130 -0
  90. package/src/shared/redirect.test.ts +49 -0
  91. package/src/shared/redirect.ts +52 -0
  92. package/src/shared/theme.test.ts +24 -0
  93. package/src/shared/theme.ts +68 -0
  94. package/src/shared/time.ts +13 -0
  95. package/src/ssr/AdminLayout.tsx +7 -3
  96. package/src/ssr/AdminSidebar.tsx +115 -49
  97. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  98. package/src/ssr/Footer.island.tsx +3 -8
  99. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  100. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  101. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  102. package/src/ssr/Layout.tsx +74 -66
  103. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  104. package/src/ssr/LayoutHelp.tsx +266 -0
  105. package/src/ssr/NavMenu.island.tsx +0 -39
  106. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  107. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  108. package/src/ssr/islands/index.ts +13 -0
  109. package/src/styles/base-popover.css +5 -2
  110. package/src/styles/effects.css +87 -6
  111. package/src/styles/global.css +146 -9
  112. package/src/styles/input.css +3 -1
  113. package/src/styles/utilities-buttons.css +133 -27
  114. package/src/styles/utilities-code-display.css +67 -0
  115. package/src/styles/utilities-completion.css +223 -0
  116. package/src/styles/utilities-detail.css +73 -0
  117. package/src/styles/utilities-feedback.css +16 -15
  118. package/src/styles/utilities-layout.css +42 -2
  119. package/src/styles/utilities-markdown-editor.css +472 -0
  120. package/src/styles/utilities-navigation.css +63 -8
  121. package/src/styles/utilities-script.css +84 -0
  122. package/src/styles/utilities-table-tile.css +229 -0
  123. package/src/types/ambient.d.ts +9 -0
  124. package/src/ui/completion/behaviors.test.ts +95 -0
  125. package/src/ui/completion/behaviors.ts +205 -0
  126. package/src/ui/completion/engine.ts +368 -0
  127. package/src/ui/completion/index.ts +40 -0
  128. package/src/ui/completion/overlay.ts +92 -0
  129. package/src/ui/dialog-core.ts +173 -45
  130. package/src/ui/filter/FilterChip.tsx +42 -40
  131. package/src/ui/index.ts +11 -12
  132. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  133. package/src/ui/input/CheckboxCard.tsx +91 -0
  134. package/src/ui/input/Combobox.tsx +375 -0
  135. package/src/ui/input/DatePicker.tsx +846 -0
  136. package/src/ui/input/DateTimeInput.tsx +29 -4
  137. package/src/ui/input/FileDropzone.tsx +116 -0
  138. package/src/ui/input/IconInput.tsx +116 -0
  139. package/src/ui/input/ImageInput.tsx +19 -2
  140. package/src/ui/input/MultiSelectInput.tsx +448 -0
  141. package/src/ui/input/NumberInput.tsx +417 -61
  142. package/src/ui/input/SegmentedControl.tsx +2 -2
  143. package/src/ui/input/Select.tsx +172 -10
  144. package/src/ui/input/Slider.tsx +3 -4
  145. package/src/ui/input/Switch.tsx +3 -2
  146. package/src/ui/input/TemplateEditor.tsx +212 -0
  147. package/src/ui/input/TextInput.tsx +144 -13
  148. package/src/ui/input/index.ts +53 -8
  149. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  150. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  151. package/src/ui/input/markdown/actions.ts +233 -0
  152. package/src/ui/input/markdown/active-formats.ts +94 -0
  153. package/src/ui/input/markdown/behaviors.ts +193 -0
  154. package/src/ui/input/markdown/code-zone.ts +23 -0
  155. package/src/ui/input/markdown/highlight.ts +316 -0
  156. package/src/ui/layout.ts +22 -0
  157. package/src/ui/misc/AppOverview.tsx +105 -0
  158. package/src/ui/misc/AppWorkspace.tsx +607 -0
  159. package/src/ui/misc/Calendar.tsx +1291 -0
  160. package/src/ui/misc/Chart.tsx +162 -0
  161. package/src/ui/misc/CodeDisplay.tsx +54 -0
  162. package/src/ui/misc/ContextMenu.tsx +2 -2
  163. package/src/ui/misc/DataTable.tsx +269 -0
  164. package/src/ui/misc/DockWorkspace.tsx +425 -0
  165. package/src/ui/misc/Docs.tsx +153 -0
  166. package/src/ui/misc/Dropdown.tsx +2 -2
  167. package/src/ui/misc/EntitySearch.tsx +260 -129
  168. package/src/ui/misc/LinkCard.tsx +14 -2
  169. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  170. package/src/ui/misc/Pagination.tsx +31 -12
  171. package/src/ui/misc/PanelDialog.tsx +109 -0
  172. package/src/ui/misc/Panes.tsx +873 -0
  173. package/src/ui/misc/PermissionEditor.tsx +358 -262
  174. package/src/ui/misc/Placeholder.tsx +40 -0
  175. package/src/ui/misc/ProgressBar.tsx +1 -1
  176. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  177. package/src/ui/misc/SettingsModal.tsx +150 -0
  178. package/src/ui/misc/StatCell.tsx +182 -40
  179. package/src/ui/misc/StatGrid.tsx +149 -0
  180. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  181. package/src/ui/misc/code-highlight.ts +213 -0
  182. package/src/ui/misc/index.ts +93 -12
  183. package/src/ui/prompts.tsx +362 -312
  184. package/src/ui/toast.ts +384 -0
  185. package/src/ui/widgets/Widget.tsx +12 -4
  186. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  187. package/src/ui/ipa/GroupView.tsx +0 -36
  188. package/src/ui/ipa/LoginBtn.tsx +0 -16
  189. package/src/ui/ipa/UserView.tsx +0 -58
  190. package/src/ui/ipa/index.ts +0 -4
  191. package/src/ui/navigation.ts +0 -32
  192. package/src/ui/sidebar.tsx +0 -468
  193. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Generic completion engine — pure logic, no DOM mutation, no timers.
3
+ *
4
+ * Used by `<MarkdownEditor>` for markdown autocompletion and by the
5
+ * standalone `<AutocompleteEditor>` for any plain-text use case
6
+ * (formulas, code, search builders, mentions, etc.). The engine has
7
+ * no opinions on syntax: it walks a textarea-like state, classifies
8
+ * the current "token" at the caret, and delegates to user-supplied
9
+ * `suggest` functions for the actual data.
10
+ *
11
+ * Three behaviours drive the same `Completion` type:
12
+ *
13
+ * 1. **Auto-expand** — Plain (trigger-less) completions whose
14
+ * suggestions have an `expansion` field. Typing a word-boundary
15
+ * char after a matching word replaces it verbatim (handled by
16
+ * `behaviors.tryExpand`).
17
+ * 2. **Ghost preview** — The closest matching suggestion is shown
18
+ * inline at the caret in dim text. Tab inserts it.
19
+ * 3. **Dropdown** — Per-completion opt-in via `dropdown: true`.
20
+ * Lists all matches; arrow keys cycle; Enter/Tab insert.
21
+ *
22
+ * Sync and async resolution
23
+ * -------------------------
24
+ * `Completion.suggest` may return either `Suggestion[]` or
25
+ * `Promise<Suggestion[]>`. Sync callers should use `suggestSync` (fast
26
+ * path — returns `null` for async results). Editors that need to
27
+ * support async fetches use `resolveSuggestions` and orchestrate
28
+ * debounce + AbortController themselves.
29
+ *
30
+ * The engine deliberately does NOT debounce or abort — those are
31
+ * UI-level concerns. Keeping the engine free of timers makes it
32
+ * trivially testable and reusable.
33
+ */
34
+
35
+ /* ── Public types ────────────────────────────────────────── */
36
+
37
+ export type Suggestion = {
38
+ /** Shown in the ghost preview and inserted when the user accepts.
39
+ * For triggered completions, includes the trigger char (e.g.
40
+ * `"#alice"`, not `"alice"`). */
41
+ text: string;
42
+ /** Optional override for the dropdown's display label. When not
43
+ * set, the dropdown shows `text` with the active trigger char
44
+ * stripped (so `(revenue` displays as `revenue`, `@alice` as
45
+ * `alice`). Set explicitly if you want the trigger visible in the
46
+ * row (`label: text`) or want a completely different label. */
47
+ label?: string;
48
+ /** Optional auto-expand target. When set, the engine replaces a
49
+ * verbatim word match with this on word boundary, and Tab-accept
50
+ * inserts `expansion` instead of `text`. */
51
+ expansion?: string;
52
+ /** Whether accepting the suggestion should append a separating space. Default true. */
53
+ appendSpace?: boolean;
54
+ /** Optional sub-label for dropdown rows (e.g. type hint, category). */
55
+ hint?: string;
56
+ /** Optional explicit replacement range. Editors that support this
57
+ * apply `text` to `[start, end)` instead of replacing the detected
58
+ * prefix. Intended for language-server-style completions. */
59
+ textEdit?: {
60
+ start: number;
61
+ end: number;
62
+ text: string;
63
+ };
64
+ };
65
+
66
+ /**
67
+ * Context passed to a `Completion.suggest` call. Lets advanced consumers
68
+ * reach beyond the typed query (e.g. detect surrounding tokens, check
69
+ * for "inside SUM()" state in a formula, look up surrounding markdown
70
+ * context, etc.) without the engine knowing about any of it.
71
+ */
72
+ export type SuggestContext = {
73
+ /** Full text content of the editor at the moment `suggest` was called. */
74
+ fullText: string;
75
+ /** Cursor position (`selectionStart`) at the moment of the call. */
76
+ caret: number;
77
+ /** Where the active token begins in `fullText` — the trigger char's
78
+ * position for triggered completions, or the word start for plain. */
79
+ tokenStart: number;
80
+ };
81
+
82
+ export type Completion = {
83
+ /** Character that activates this completion. Omit for the trigger-less
84
+ * "plain word" mode (abbreviation-style). At most one plain completion
85
+ * is honoured per editor; additional ones are ignored. */
86
+ trigger?: string;
87
+ /**
88
+ * Return suggestions for `query`. May be sync or async. Called with
89
+ * empty `query` when the editor scans for "known labels" (document
90
+ * highlight). Sync callers fast-path; async callers go through the
91
+ * debounced path. The `signal` is set up by the caller — abort it
92
+ * yourself in long-running fetches to avoid races.
93
+ */
94
+ suggest: (query: string, ctx: SuggestContext, signal: AbortSignal) => Suggestion[] | Promise<Suggestion[]>;
95
+ /** When `true`, the editor opens a caret-anchored dropdown listing
96
+ * all matches. Default `false` — only the inline ghost preview shows. */
97
+ dropdown?: boolean;
98
+ /** By default a triggered completion only activates when the
99
+ * trigger char is NOT preceded by another word char — so `foo@bar`
100
+ * doesn't fire the `@` completion mid-email. Set `true` to allow
101
+ * activation even directly after a word, e.g. `SUM(` should fire
102
+ * the `(` completion to autocomplete column references. */
103
+ allowAfterWord?: boolean;
104
+ };
105
+
106
+ /** Returned by `detectQuery` — describes what completion run the caret
107
+ * is currently inside. */
108
+ export type QueryContext = {
109
+ /** Where the matched run begins in the editor text. Includes the
110
+ * trigger char position for triggered completions. */
111
+ start: number;
112
+ /** Where the run ends (== caret). */
113
+ end: number;
114
+ /** The typed run as it appears in the text (`"@al"`, `"mfg"`). For
115
+ * triggered completions this includes the trigger character. */
116
+ text: string;
117
+ /** Query passed to `suggest` — for triggered completions this is the
118
+ * part AFTER the trigger; for plain it equals `text`. */
119
+ query: string;
120
+ /** Which completion this run belongs to. */
121
+ completion: Completion;
122
+ };
123
+
124
+ /* ── Helpers ────────────────────────────────────────────── */
125
+
126
+ /**
127
+ * Convenience: turn a `{ short: long }` dictionary into a plain
128
+ * (trigger-less) `Completion`. Each entry becomes a suggestion with
129
+ * `expansion` set, so it participates in both ghost preview and
130
+ * word-boundary auto-expand.
131
+ *
132
+ * Case-insensitive lookup with exact-case preference.
133
+ */
134
+ export const abbreviations = (dict: Record<string, string>): Completion => {
135
+ const keys = Object.keys(dict);
136
+ const suggestions: Suggestion[] = keys.map((key) => ({ text: key, expansion: dict[key]! }));
137
+
138
+ return {
139
+ suggest: (query: string) => {
140
+ if (query === "") return suggestions;
141
+ const lowerQ = query.toLowerCase();
142
+ const out: Suggestion[] = [];
143
+ for (const s of suggestions) {
144
+ if (s.text === query || s.text.toLowerCase().startsWith(lowerQ)) out.push(s);
145
+ }
146
+ out.sort((a, b) => {
147
+ const aExact = a.text.startsWith(query) ? 0 : 1;
148
+ const bExact = b.text.startsWith(query) ? 0 : 1;
149
+ return aExact - bExact;
150
+ });
151
+ return out;
152
+ },
153
+ };
154
+ };
155
+
156
+ /* ── Constants ──────────────────────────────────────────── */
157
+
158
+ /** Characters that close a word and may trigger an abbreviation
159
+ * expansion. Anything "punctuation-y" outside the word body counts. */
160
+ export const TRIGGER_CHARS = new Set([" ", "\t", "\n", ",", ".", "!", "?", ";", ":", ")", "]", "}", '"', "'"]);
161
+
162
+ /** Unicode word-char regex — letters, numbers, underscore. */
163
+ export const WORD_CHAR = /[\p{L}\p{N}_]/u;
164
+
165
+ /**
166
+ * Sentinel placed at the cursor when rendering an overlay preview so
167
+ * ghost / anchor HTML can be substituted in post-render. PUA codepoint
168
+ * so it can't appear in user input or be matched by any text regex.
169
+ */
170
+ export const GHOST_SENTINEL = String.fromCharCode(0xe010);
171
+
172
+ /* ── Query detection ────────────────────────────────────── */
173
+
174
+ export type DetectOptions = {
175
+ /** Optional predicate: when it returns true for `(text, pos)`, the
176
+ * engine skips that position entirely (used by MarkdownEditor to
177
+ * disable completions inside code spans / fences). Default: never
178
+ * exclude. */
179
+ isExcluded?: (text: string, pos: number) => boolean;
180
+ };
181
+
182
+ /**
183
+ * Inspect the textarea state and figure out which completion (if any)
184
+ * is currently being typed at the caret. Returns null when nothing
185
+ * matches OR when the caret sits mid-word (so `m|ittag` doesn't
186
+ * suggest things just because `m` is a prefix).
187
+ *
188
+ * Priority: a triggered completion (its trigger char directly before
189
+ * the word) wins over the plain (trigger-less) completion.
190
+ */
191
+ export const detectQuery = (
192
+ textarea: HTMLTextAreaElement,
193
+ completions: Completion[] | undefined,
194
+ options: DetectOptions = {},
195
+ ): QueryContext | null => {
196
+ if (!completions || completions.length === 0) return null;
197
+ if (textarea.selectionStart !== textarea.selectionEnd) return null;
198
+
199
+ const value = textarea.value;
200
+ const caret = textarea.selectionStart;
201
+ if (caret === 0) return null;
202
+ if (options.isExcluded?.(value, caret)) return null;
203
+
204
+ // Only fire when the caret sits at the END of a word — never
205
+ // mid-word. End-of-buffer counts as "after a word".
206
+ const charAfterCaret = value[caret];
207
+ if (charAfterCaret !== undefined && WORD_CHAR.test(charAfterCaret)) return null;
208
+
209
+ // Walk back from caret over word chars to find the run start.
210
+ let wordStart = caret;
211
+ while (wordStart > 0 && WORD_CHAR.test(value[wordStart - 1]!)) wordStart--;
212
+ const triggerCandidate = wordStart > 0 ? value[wordStart - 1] : undefined;
213
+
214
+ // Triggered completion: trigger char directly before the word AND
215
+ // (by default) not preceded by another word char — so `foo#bar`
216
+ // doesn't activate `#`. Completions that legitimately fire after
217
+ // a word (e.g. `SUM(` for formula args) opt in via `allowAfterWord`.
218
+ if (triggerCandidate) {
219
+ const triggered = completions.find((c) => c.trigger === triggerCandidate);
220
+ if (triggered) {
221
+ const beforeTrigger = wordStart >= 2 ? value[wordStart - 2] : undefined;
222
+ const boundaryOk = !beforeTrigger || !WORD_CHAR.test(beforeTrigger);
223
+ if (boundaryOk || triggered.allowAfterWord) {
224
+ const query = value.slice(wordStart, caret);
225
+ return {
226
+ start: wordStart - 1,
227
+ end: caret,
228
+ text: triggerCandidate + query,
229
+ query,
230
+ completion: triggered,
231
+ };
232
+ }
233
+ }
234
+ }
235
+
236
+ // Plain completion. Requires at least one word char and that the
237
+ // char before the word isn't itself a word char.
238
+ if (caret > wordStart) {
239
+ const plain = completions.find((c) => c.trigger === undefined);
240
+ if (plain) {
241
+ const query = value.slice(wordStart, caret);
242
+ return { start: wordStart, end: caret, text: query, query, completion: plain };
243
+ }
244
+ }
245
+
246
+ return null;
247
+ };
248
+
249
+ /* ── Suggestion resolution ──────────────────────────────── */
250
+
251
+ /**
252
+ * Discriminated-union result of resolving a completion's `suggest`.
253
+ * Lets the caller fast-path on `kind === "sync"` and await the
254
+ * promise on `kind === "async"`.
255
+ */
256
+ export type ResolveResult = { kind: "sync"; data: Suggestion[] } | { kind: "async"; promise: Promise<Suggestion[]> };
257
+
258
+ /**
259
+ * Call `completion.suggest` and discriminate between sync and async
260
+ * results. The caller owns the `AbortController`; abort it when the
261
+ * query changes mid-flight to drop the stale response.
262
+ */
263
+ export const resolveSuggestions = (completion: Completion, query: string, ctx: SuggestContext, signal: AbortSignal): ResolveResult => {
264
+ const r = completion.suggest(query, ctx, signal);
265
+ if (r instanceof Promise) return { kind: "async", promise: r };
266
+ return { kind: "sync", data: r };
267
+ };
268
+
269
+ /**
270
+ * Sync-only convenience: returns suggestions if the completion is
271
+ * synchronous, `null` otherwise (caller handles async via the
272
+ * debounced/abort path). Used by helpers like `collectKnownLabels`
273
+ * and the auto-expand lookup that can't wait for a promise.
274
+ *
275
+ * When the suggest returns a Promise, we abort the controller (so
276
+ * the user code can short-circuit if it checks the signal) AND
277
+ * attach a no-op `.catch` to suppress the eventual rejection. Without
278
+ * this, async suggests that reject would surface as unhandled
279
+ * promise rejections — in some runtimes (notably Bun during SSR
280
+ * rendering) this aborts the render and triggers a dev-server
281
+ * reload. The promise here is dead-end by design (we don't want
282
+ * its value), so silently dropping any rejection is correct.
283
+ */
284
+ export const suggestSync = (completion: Completion, query: string, ctx: SuggestContext): Suggestion[] | null => {
285
+ const ctrl = new AbortController();
286
+ let result: Suggestion[] | Promise<Suggestion[]>;
287
+ try {
288
+ result = completion.suggest(query, ctx, ctrl.signal);
289
+ } catch {
290
+ // User suggest threw synchronously — treat as "no result".
291
+ return null;
292
+ }
293
+ if (Array.isArray(result)) return result;
294
+ ctrl.abort();
295
+ result.catch(() => {
296
+ /* intentionally swallowed — caller didn't await this promise */
297
+ });
298
+ return null;
299
+ };
300
+
301
+ /**
302
+ * Pick the best ghost suggestion. Compares against the full typed
303
+ * string (`typed`), which for triggered completions INCLUDES the
304
+ * trigger char so the prefix check aligns with `suggestion.text`.
305
+ * Skips suggestions equal in length to the typed text (nothing to
306
+ * ghost).
307
+ */
308
+ export const pickGhost = (suggestions: Suggestion[], typed: string): Suggestion | null => {
309
+ const lower = typed.toLowerCase();
310
+ for (const s of suggestions) {
311
+ if (s.text.length <= typed.length) continue;
312
+ if (s.text.toLowerCase().startsWith(lower)) return s;
313
+ }
314
+ return null;
315
+ };
316
+
317
+ /**
318
+ * Walk all sync completions, ask each one for its full suggestion
319
+ * list (`suggest("")`), and return a flat set of `text` values. The
320
+ * MarkdownEditor uses this set to highlight known labels in the
321
+ * rendered preview. Async completions are silently skipped (we can't
322
+ * pause document rendering for a fetch).
323
+ *
324
+ * A "scan ctx" with empty `fullText`/`caret`/`tokenStart` is passed —
325
+ * `suggest` implementations should treat `query === ""` as a scan
326
+ * call.
327
+ */
328
+ export const collectKnownLabels = (completions: Completion[] | undefined): Set<string> => {
329
+ const labels = new Set<string>();
330
+ if (!completions) return labels;
331
+ const scanCtx: SuggestContext = { fullText: "", caret: 0, tokenStart: 0 };
332
+ for (const c of completions) {
333
+ const result = suggestSync(c, "", scanCtx);
334
+ if (!result) continue;
335
+ for (const s of result) labels.add(s.text);
336
+ }
337
+ return labels;
338
+ };
339
+
340
+ /**
341
+ * Resolve what to show for a suggestion in the dropdown row. Order
342
+ * of precedence:
343
+ * 1. Explicit `suggestion.label` if set (caller takes full control).
344
+ * 2. `suggestion.text` with the active completion's trigger char
345
+ * stripped, when present — so `(revenue` displays as `revenue`,
346
+ * `@alice` as `alice`. Avoids visually doubling the trigger
347
+ * since the user already typed it.
348
+ * 3. `suggestion.text` as-is when there's no trigger to strip.
349
+ */
350
+ export const displayLabel = (suggestion: Suggestion, completion: Completion): string => {
351
+ if (suggestion.label !== undefined) return suggestion.label;
352
+ const trigger = completion.trigger;
353
+ if (trigger && suggestion.text.startsWith(trigger)) {
354
+ return suggestion.text.slice(trigger.length);
355
+ }
356
+ return suggestion.text;
357
+ };
358
+
359
+ /**
360
+ * Build a `SuggestContext` from a textarea + query context. The
361
+ * editor builds this before calling `suggest` (sync or async) — the
362
+ * engine doesn't, because it doesn't own the textarea state.
363
+ */
364
+ export const buildSuggestContext = (textarea: HTMLTextAreaElement, queryCtx: QueryContext): SuggestContext => ({
365
+ fullText: textarea.value,
366
+ caret: textarea.selectionStart,
367
+ tokenStart: queryCtx.start,
368
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Generic completion system used by both `<MarkdownEditor>` and
3
+ * `<AutocompleteEditor>`. Engine is pure logic, behaviours touch the
4
+ * DOM via `execCommand`, overlay renders ghost + anchor for editors
5
+ * that mirror their textarea in a preview div.
6
+ *
7
+ * For most callers, the high-level entrypoint is `<AutocompleteEditor>`
8
+ * (see `../input/AutocompleteEditor.tsx`). Direct engine access here
9
+ * is for editors that compose their own UI on top.
10
+ */
11
+
12
+ export {
13
+ type Suggestion,
14
+ type SuggestContext,
15
+ type Completion,
16
+ type QueryContext,
17
+ type DetectOptions,
18
+ type ResolveResult,
19
+ TRIGGER_CHARS,
20
+ WORD_CHAR,
21
+ GHOST_SENTINEL,
22
+ abbreviations,
23
+ detectQuery,
24
+ resolveSuggestions,
25
+ suggestSync,
26
+ pickGhost,
27
+ collectKnownLabels,
28
+ buildSuggestContext,
29
+ displayLabel,
30
+ } from "./engine";
31
+
32
+ export {
33
+ type TryExpandOptions,
34
+ resetCompletionState,
35
+ tryExpand,
36
+ tryRestore,
37
+ applySuggestion,
38
+ } from "./behaviors";
39
+
40
+ export { type RenderOptions, plainTextHighlight, renderWithOverlay } from "./overlay";
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Overlay-mode rendering helpers for editors that mirror their
3
+ * textarea in a preview div (the "overtype" pattern).
4
+ *
5
+ * The renderer injects a `GHOST_SENTINEL` PUA char at the caret in
6
+ * the source text BEFORE the caller's `highlight` function runs.
7
+ * After highlighting, the sentinel is replaced with either:
8
+ *
9
+ * - A `<span class="completion-ghost" data-completion-anchor>` containing
10
+ * the not-yet-typed tail of the active suggestion + a `→` arrow.
11
+ * - An invisible `<span class="completion-caret-anchor" data-completion-anchor>`
12
+ * used purely for positioning the dropdown when no ghost is shown.
13
+ *
14
+ * The `data-completion-anchor` attribute is what `positionDropdown`
15
+ * queries to find the caret's pixel position. The same attribute on
16
+ * both shapes lets the editor's positioning code stay agnostic of
17
+ * which shape is currently rendered.
18
+ *
19
+ * This module is markdown-agnostic. The `highlight` callback is the
20
+ * caller's responsibility — MarkdownEditor passes its markdown
21
+ * highlighter, AutocompleteEditor passes either an identity-escape
22
+ * or a user-provided syntax highlighter.
23
+ */
24
+
25
+ import { GHOST_SENTINEL } from "./engine";
26
+
27
+ const escapeHtml = (s: string): string =>
28
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
29
+
30
+ /**
31
+ * Identity highlighter — escapes HTML and preserves whitespace 1:1.
32
+ * Use this when the editor has no syntax-highlighting needs but
33
+ * still wants the overlay (for ghost preview / dropdown anchor).
34
+ */
35
+ export const plainTextHighlight = (text: string): string => escapeHtml(text);
36
+
37
+ export type RenderOptions = {
38
+ /** Optional ghost preview to inject at `at` (the cursor offset in
39
+ * `text`). The wrapper carries `data-completion-anchor`. */
40
+ ghost?: { at: number; text: string };
41
+ /** Optional invisible anchor (used when a dropdown is open but no
42
+ * ghost is shown — e.g. typed text equals the highlighted row). */
43
+ anchor?: { at: number };
44
+ };
45
+
46
+ /**
47
+ * Render text through `highlight`, then substitute the sentinel with
48
+ * either a ghost wrapper or an invisible anchor. Both carry
49
+ * `data-completion-anchor` so the editor can `querySelector` for
50
+ * positioning.
51
+ *
52
+ * The `highlight` function receives text that may contain the
53
+ * sentinel. The sentinel is a private-use codepoint with no special
54
+ * meaning to any markdown/HTML regex, so highlighters generally pass
55
+ * it through untouched — it ends up in the output verbatim, then
56
+ * gets substituted here.
57
+ */
58
+ export const renderWithOverlay = (
59
+ text: string,
60
+ highlight: (text: string) => string,
61
+ options: RenderOptions = {},
62
+ ): string => {
63
+ const { ghost, anchor } = options;
64
+
65
+ // Ghost wins over anchor: when both are conceptually present, the
66
+ // ghost wrapper already carries the anchor attribute, no separate
67
+ // marker needed. Only inject when either is requested.
68
+ const injection = ghost ?? anchor;
69
+ let workText = text;
70
+ if (injection) {
71
+ workText = text.slice(0, injection.at) + GHOST_SENTINEL + text.slice(injection.at);
72
+ }
73
+
74
+ let html = highlight(workText);
75
+
76
+ if (ghost) {
77
+ // Plain Unicode arrow rather than an icon-font glyph — overlays
78
+ // typically lock `font-family` to monospace (overtype pattern),
79
+ // so an icon font wouldn't take effect and we'd get a tofu box.
80
+ // `→` (U+2192) is available in every monospace font.
81
+ const ghostHtml = `<span class="completion-ghost" data-completion-anchor>${escapeHtml(ghost.text)}<span class="completion-ghost-arrow" aria-hidden="true">→</span></span>`;
82
+ html = html.split(GHOST_SENTINEL).join(ghostHtml);
83
+ } else if (anchor) {
84
+ // Zero-width inline-block so layout doesn't shift but
85
+ // `getBoundingClientRect()` returns the caret's pixel position.
86
+ // `​` keeps the span "non-empty" for browsers that collapse
87
+ // empty inline elements to zero size.
88
+ const anchorHtml = `<span class="completion-caret-anchor" data-completion-anchor aria-hidden="true">​</span>`;
89
+ html = html.split(GHOST_SENTINEL).join(anchorHtml);
90
+ }
91
+ return html;
92
+ };