@valentinkolb/cloud 0.3.1 → 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 (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Markdown highlighter for the overtype-style editor.
3
+ *
4
+ * Input: plain text (the textarea's current value).
5
+ * Output: an HTML string that's injected into the preview div via
6
+ * `innerHTML`. Whitespace is preserved 1:1 because both layers run with
7
+ * `white-space: pre-wrap`. Newlines in the output are real `\n`
8
+ * characters — the preview never adds extra wrappers around lines.
9
+ *
10
+ * The visible markdown syntax characters (`**`, `#`, `-`, etc.) are kept
11
+ * in the output, wrapped in a dimmed `.md-syntax` span. This is the
12
+ * core of the overtype trick: the textarea's char positions must match
13
+ * the preview's char positions exactly, so we can't elide any source
14
+ * characters in the rendered version.
15
+ *
16
+ * Parsing happens in three passes:
17
+ *
18
+ * 1. HTML-escape the entire text.
19
+ * 2. Sanctuary extraction: pull inline-code spans (`` `…` ``) and
20
+ * links (`[text](url)`) out into placeholder tokens BEFORE any
21
+ * other regex runs. This prevents `*italic*` from matching inside
22
+ * a URL, and prevents code-span content from being mistaken for
23
+ * bold/italic markdown. Lesson from overtype issue #81.
24
+ * 3. Block + inline pass per line, then placeholders are restored.
25
+ */
26
+
27
+ // Private Use Area chars for sanctuary placeholders — they will never
28
+ // appear in user input and won't be matched by any markdown regex.
29
+ const PH_OPEN = String.fromCharCode(0xe000);
30
+ const PH_CLOSE = String.fromCharCode(0xe001);
31
+
32
+ const escapeHtml = (s: string): string =>
33
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
34
+
35
+ // NOTE: link previews are rendered as `<span>`, never `<a href>`, so
36
+ // the editor itself never produces a clickable target — clicks pass
37
+ // through the textarea overlay anyway. URL sanitisation only matters
38
+ // in the downstream renderer (the `.prose` HTML in read-mode), not
39
+ // here. Keeping link payloads as escaped text + dim-coloured syntax
40
+ // is enough for the preview-as-cursor-alignment-mirror use case.
41
+
42
+ type Sanctuaries = Map<string, string>;
43
+
44
+ const extractSanctuaries = (input: string): { text: string; sanctuaries: Sanctuaries } => {
45
+ const sanctuaries: Sanctuaries = new Map();
46
+ let counter = 0;
47
+ const issue = (): string => `${PH_OPEN}${counter++}${PH_CLOSE}`;
48
+
49
+ let text = input;
50
+
51
+ // Inline code first — match runs of N backticks paired by N backticks.
52
+ // Content is restricted to single-line (no `\n`) so this regex can't
53
+ // accidentally swallow a fenced code block like ```\n…\n``` — fences
54
+ // are detected later in the block-level pass. Mirrors overtype's
55
+ // parser logic but with stricter single-line scope.
56
+ text = text.replace(/(?<!`)(`+)(?!`)([^\n]+?)\1(?!`)/g, (_match, ticks: string, content: string) => {
57
+ const ph = issue();
58
+ sanctuaries.set(
59
+ ph,
60
+ `<span class="md-code"><span class="md-syntax">${ticks}</span>${content}<span class="md-syntax">${ticks}</span></span>`,
61
+ );
62
+ return ph;
63
+ });
64
+
65
+ // Then links [text](url). HTML was escaped earlier, so brackets show
66
+ // as literal `[` / `]` here. The URL part is rendered as plain dim
67
+ // text (not as a clickable anchor target) inside the syntax span; the
68
+ // outer <a> wraps the whole construct so the visible label is what's
69
+ // hoverable. The textarea sits on top with pointer-events, so clicks
70
+ // don't follow the link by default — that's intentional, otherwise
71
+ // selecting text by clicking would navigate away mid-edit.
72
+ text = text.replace(/\[([^\]\n]+?)\]\(([^)\n]+?)\)/g, (_match, label: string, url: string) => {
73
+ const ph = issue();
74
+ sanctuaries.set(
75
+ ph,
76
+ `<span class="md-link"><span class="md-syntax">[</span>${label}<span class="md-syntax">](${url})</span></span>`,
77
+ );
78
+ return ph;
79
+ });
80
+
81
+ return { text, sanctuaries };
82
+ };
83
+
84
+ const restoreSanctuaries = (html: string, sanctuaries: Sanctuaries): string => {
85
+ // Sanctuaries are unique unicode markers; a single split/join per
86
+ // placeholder is cheap and avoids any regex parsing of the markers.
87
+ //
88
+ // Order matters: we restore in REVERSE insertion order so that
89
+ // outer wrappers (links) are restored before their inner content
90
+ // (code spans) — the link's stored HTML contains the code
91
+ // placeholder, and we need that placeholder to land in the live
92
+ // string BEFORE we try to substitute it with the code HTML. Without
93
+ // this, `[`x`](url)` would leak the raw code placeholder into the
94
+ // rendered link label.
95
+ const entries = [...sanctuaries].reverse();
96
+ for (const [ph, replacement] of entries) {
97
+ if (html.includes(ph)) html = html.split(ph).join(replacement);
98
+ }
99
+ return html;
100
+ };
101
+
102
+ // Separate PUA range for the bold sanctuary, so its placeholders can't
103
+ // collide with the outer code/link sanctuaries (those use /).
104
+ const BOLD_PH_OPEN = String.fromCharCode(0xe002);
105
+ const BOLD_PH_CLOSE = String.fromCharCode(0xe003);
106
+
107
+ // Third PUA range for the completion-match sanctuary. Distinct from
108
+ // the code/link and bold sanctuaries so each pipeline stage can
109
+ // restore its own placeholders without collisions.
110
+ const MATCH_PH_OPEN = String.fromCharCode(0xe004);
111
+ const MATCH_PH_CLOSE = String.fromCharCode(0xe005);
112
+
113
+ // Escape a string for safe use as a regex literal. Used when building
114
+ // the alternation of known completion labels for the document scan.
115
+ const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
116
+
117
+ /** Build a regex that matches any of `labels` as a standalone word.
118
+ * The negative lookarounds use word-character classes so a label
119
+ * starting with `#` like `#alice` still matches in plain prose
120
+ * (because `#` is not a word char, the leading boundary collapses
121
+ * to "anything but a word char"). */
122
+ const buildMatchRegex = (labels: Set<string>): RegExp | null => {
123
+ if (labels.size === 0) return null;
124
+ const sorted = [...labels].sort((a, b) => b.length - a.length).map(escapeRegex);
125
+ return new RegExp(`(?<![\\p{L}\\p{N}_])(${sorted.join("|")})(?![\\p{L}\\p{N}_])`, "gu");
126
+ };
127
+
128
+ /** Italic-only pass — split out so we can call it recursively on a
129
+ * bold span's inner content (so things like `**foo *bar* baz**`
130
+ * render with italic inside bold). */
131
+ const processItalic = (text: string): string => {
132
+ text = text.replace(
133
+ /(^|[^*\w])\*([^\s*][^*\n]*?[^\s*]|[^\s*])\*(?!\*)/g,
134
+ '$1<span class="md-italic"><span class="md-syntax">*</span>$2<span class="md-syntax">*</span></span>',
135
+ );
136
+ text = text.replace(
137
+ /(^|[^\w_])_([^\s_][^_\n]*?[^\s_]|[^\s_])_(?!\w)/g,
138
+ '$1<span class="md-italic"><span class="md-syntax">_</span>$2<span class="md-syntax">_</span></span>',
139
+ );
140
+ return text;
141
+ };
142
+
143
+ /**
144
+ * Inline pass — bold + italic with nesting support.
145
+ *
146
+ * Naïve order (bold-then-italic) breaks symmetric nesting:
147
+ * - `*foo **bar** baz*` — the outer italic can't span the bold span
148
+ * because the bold pass writes `*` into the visible syntax markers
149
+ * and the italic regex can't cross those literal asterisks.
150
+ *
151
+ * Fix: bold is sanctuarised first. The bold content is recursively
152
+ * italic-processed and stored under a placeholder (PUA chars), so the
153
+ * italic pass sees only plain text + opaque markers. Both directions
154
+ * of nesting (italic-in-bold and bold-in-italic) work.
155
+ *
156
+ * Bold's inner-content regex `(?:[^*\n]|\*[^*\n]+?\*)+?` allows either
157
+ * plain non-asterisk chars OR an `*…*` italic pair, so a bold can
158
+ * still match across embedded italic — same pattern, mirrored for `__`.
159
+ */
160
+ const processInline = (text: string, matchRegex: RegExp | null = null): string => {
161
+ const sanctuaries = new Map<string, string>();
162
+ let counter = 0;
163
+ const issue = (): string => `${BOLD_PH_OPEN}${counter++}${BOLD_PH_CLOSE}`;
164
+
165
+ text = text.replace(/\*\*((?:[^*\n]|\*[^*\n]+?\*)+?)\*\*/g, (_match, inner: string) => {
166
+ // Recurse with the SAME match regex so labels inside bold spans
167
+ // are highlighted too.
168
+ const innerProcessed = processItalicAndMatches(inner, matchRegex);
169
+ const ph = issue();
170
+ sanctuaries.set(
171
+ ph,
172
+ `<span class="md-bold"><span class="md-syntax">**</span>${innerProcessed}<span class="md-syntax">**</span></span>`,
173
+ );
174
+ return ph;
175
+ });
176
+ text = text.replace(/__((?:[^_\n]|_[^_\n]+?_)+?)__/g, (_match, inner: string) => {
177
+ const innerProcessed = processItalicAndMatches(inner, matchRegex);
178
+ const ph = issue();
179
+ sanctuaries.set(
180
+ ph,
181
+ `<span class="md-bold"><span class="md-syntax">__</span>${innerProcessed}<span class="md-syntax">__</span></span>`,
182
+ );
183
+ return ph;
184
+ });
185
+
186
+ text = processItalicAndMatches(text, matchRegex);
187
+
188
+ for (const [ph, html] of sanctuaries) {
189
+ if (text.includes(ph)) text = text.split(ph).join(html);
190
+ }
191
+ return text;
192
+ };
193
+
194
+ /** Match-extract + italic pass. Pulled into its own helper so bold
195
+ * recursion shares the same logic. Matches are extracted to a third
196
+ * PUA sanctuary BEFORE italic so the italic regex doesn't trip on
197
+ * `<span>` chars inside a wrapped label. */
198
+ const processItalicAndMatches = (text: string, matchRegex: RegExp | null): string => {
199
+ if (!matchRegex) return processItalic(text);
200
+ const matchSanctuary = new Map<string, string>();
201
+ let counter = 0;
202
+ const issue = (): string => `${MATCH_PH_OPEN}${counter++}${MATCH_PH_CLOSE}`;
203
+ // Reset regex state — buildMatchRegex returns a `/g` regex which
204
+ // carries lastIndex between calls.
205
+ matchRegex.lastIndex = 0;
206
+ text = text.replace(matchRegex, (label: string) => {
207
+ const ph = issue();
208
+ matchSanctuary.set(ph, `<span class="md-completion-match">${label}</span>`);
209
+ return ph;
210
+ });
211
+ text = processItalic(text);
212
+ for (const [ph, html] of matchSanctuary) {
213
+ if (text.includes(ph)) text = text.split(ph).join(html);
214
+ }
215
+ return text;
216
+ };
217
+
218
+ /**
219
+ * Block-level transform for a single line (no newline inside `line`).
220
+ */
221
+ const processLine = (line: string, matchRegex: RegExp | null = null): string => {
222
+ // Empty line — keep as-is so the preview's pre-wrap renders a blank
223
+ // line at the same position as in the textarea.
224
+ if (line.length === 0) return "";
225
+
226
+ // Header (1–3 hashes only — h4+ aren't typographically distinct in
227
+ // our font-weight-only scheme, so we don't pretend to support them).
228
+ const header = /^(#{1,3})(\s)(.*)$/.exec(line);
229
+ if (header) {
230
+ const [, hashes, ws, content] = header;
231
+ return `<span class="md-h${hashes!.length}"><span class="md-syntax">${hashes}${ws}</span>${processInline(content!, matchRegex)}</span>`;
232
+ }
233
+
234
+ // Horizontal rule — `---`, `***`, or `___` alone on a line.
235
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
236
+ return `<span class="md-hr">${line}</span>`;
237
+ }
238
+
239
+ // Blockquote (`>` is HTML-escaped to `&gt;` by this point).
240
+ const quote = /^(&gt;)(\s)(.*)$/.exec(line);
241
+ if (quote) {
242
+ return `<span class="md-quote"><span class="md-syntax">${quote[1]}${quote[2]}</span>${processInline(quote[3]!, matchRegex)}</span>`;
243
+ }
244
+
245
+ // Bullet list: `- `, `* `, `+ ` with optional leading indent.
246
+ const bullet = /^(\s*)([-*+])(\s)(.*)$/.exec(line);
247
+ if (bullet) {
248
+ const [, indent, marker, ws, content] = bullet;
249
+ return `${indent}<span class="md-marker">${marker}${ws}</span>${processInline(content!, matchRegex)}`;
250
+ }
251
+
252
+ // Numbered list: `1. ` etc.
253
+ const numbered = /^(\s*)(\d+\.)(\s)(.*)$/.exec(line);
254
+ if (numbered) {
255
+ const [, indent, marker, ws, content] = numbered;
256
+ return `${indent}<span class="md-marker">${marker}${ws}</span>${processInline(content!, matchRegex)}`;
257
+ }
258
+
259
+ // Plain paragraph line — just inline pass.
260
+ return processInline(line, matchRegex);
261
+ };
262
+
263
+ export type HighlightOptions = {
264
+ /** When set, every occurrence of these labels (as a standalone word
265
+ * outside code spans/fences) is wrapped in `.md-completion-match`
266
+ * for the document-wide highlight effect. Pass the set returned by
267
+ * `collectKnownLabels` from `completions.ts`. */
268
+ knownLabels?: Set<string>;
269
+ };
270
+
271
+ /**
272
+ * Render a markdown string as syntax-highlighted HTML for the preview
273
+ * layer. The output is safe to insert via `innerHTML` because the input
274
+ * is HTML-escaped before any markdown transforms run.
275
+ */
276
+ export const highlightMarkdown = (text: string, options: HighlightOptions = {}): string => {
277
+ const matchRegex = options.knownLabels ? buildMatchRegex(options.knownLabels) : null;
278
+ const escaped = escapeHtml(text);
279
+ const { text: protectedText, sanctuaries } = extractSanctuaries(escaped);
280
+
281
+ const lines = protectedText.split("\n");
282
+ const out: string[] = [];
283
+ let inCodeFence = false;
284
+
285
+ for (const line of lines) {
286
+ // Fence open/close — three backticks at the start of a line. The
287
+ // backticks were NOT consumed by the inline-code sanctuary pass
288
+ // because that requires content between the ticks; a bare ``` line
289
+ // has none.
290
+ if (/^```/.test(line)) {
291
+ inCodeFence = !inCodeFence;
292
+ out.push(`<span class="md-code-block md-syntax">${line}</span>`);
293
+ continue;
294
+ }
295
+ if (inCodeFence) {
296
+ // Inside a fence: render verbatim with code-block background, no
297
+ // further markdown processing.
298
+ out.push(`<span class="md-code-block">${line}</span>`);
299
+ continue;
300
+ }
301
+ out.push(processLine(line, matchRegex));
302
+ }
303
+
304
+ const html = restoreSanctuaries(out.join("\n"), sanctuaries);
305
+
306
+ // A `<textarea>` reserves an empty final line for the caret when its
307
+ // value ends in "\n", but a `white-space: pre-wrap` block swallows
308
+ // that single trailing newline and renders one line shorter. The
309
+ // mismatch makes the preview's scrollHeight smaller than the
310
+ // textarea's, so the scroll-sync clamps and the visible text drifts
311
+ // up to one line near the bottom. Append one extra newline to mirror
312
+ // the textarea's phantom last line. Only the LAST trailing newline is
313
+ // dropped by the layout, so a single extra suffices for any run of
314
+ // trailing blanks.
315
+ return text.endsWith("\n") ? `${html}\n` : html;
316
+ };
@@ -0,0 +1,22 @@
1
+ export type LayoutBreadcrumb = {
2
+ title: string;
3
+ href?: string;
4
+ };
5
+
6
+ export type LayoutUpdate = {
7
+ title?: string;
8
+ breadcrumbs?: LayoutBreadcrumb[];
9
+ };
10
+
11
+ export const LAYOUT_UPDATE_EVENT = "cloud:layout:update";
12
+
13
+ export const layout = {
14
+ update(update: LayoutUpdate) {
15
+ if (typeof window === "undefined") return;
16
+
17
+ const title = update.title ?? update.breadcrumbs?.at(-1)?.title;
18
+ if (title) document.title = title;
19
+
20
+ window.dispatchEvent(new CustomEvent<LayoutUpdate>(LAYOUT_UPDATE_EVENT, { detail: update }));
21
+ },
22
+ };
@@ -0,0 +1,105 @@
1
+ import { Show, type JSX } from "solid-js";
2
+
3
+ export type AppOverviewProps = {
4
+ title: string;
5
+ subtitle?: string;
6
+ icon: string;
7
+ class?: string;
8
+ children: JSX.Element;
9
+ };
10
+
11
+ export type AppOverviewPanelProps = {
12
+ title: string;
13
+ description?: JSX.Element;
14
+ toolbar?: JSX.Element;
15
+ class?: string;
16
+ children: JSX.Element;
17
+ };
18
+
19
+ export type AppOverviewEmptyStateProps = {
20
+ title: string;
21
+ description?: JSX.Element;
22
+ icon?: string;
23
+ class?: string;
24
+ children?: JSX.Element;
25
+ };
26
+
27
+ type AppOverviewComponent = ((props: AppOverviewProps) => JSX.Element) & {
28
+ Main: (props: AppOverviewPanelProps) => JSX.Element;
29
+ Aside: (props: AppOverviewPanelProps) => JSX.Element;
30
+ EmptyState: (props: AppOverviewEmptyStateProps) => JSX.Element;
31
+ };
32
+
33
+ const tablerIconClass = (icon: string | null | undefined, fallback: string): string => {
34
+ const value = icon?.trim() || fallback;
35
+ return value.startsWith("ti ") ? value : `ti ${value}`;
36
+ };
37
+
38
+ const PanelHeader = (props: Pick<AppOverviewPanelProps, "title" | "description" | "toolbar">) => (
39
+ <div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
40
+ <div class="min-w-0">
41
+ <h2 class="text-sm font-semibold text-primary">{props.title}</h2>
42
+ <Show when={props.description}>
43
+ <p class="text-xs text-dimmed">{props.description}</p>
44
+ </Show>
45
+ </div>
46
+ <Show when={props.toolbar}>
47
+ <div class="w-full sm:w-80">{props.toolbar}</div>
48
+ </Show>
49
+ </div>
50
+ );
51
+
52
+ const AppOverviewMain = (props: AppOverviewPanelProps) => (
53
+ <section class={`min-w-0 flex-1 w-full ${props.class ?? ""}`}>
54
+ <PanelHeader title={props.title} description={props.description} toolbar={props.toolbar} />
55
+ {props.children}
56
+ </section>
57
+ );
58
+
59
+ const AppOverviewAside = (props: AppOverviewPanelProps) => (
60
+ <aside class={`min-w-0 w-full shrink-0 lg:w-96 ${props.class ?? ""}`}>
61
+ <PanelHeader title={props.title} description={props.description} toolbar={props.toolbar} />
62
+ {props.children}
63
+ </aside>
64
+ );
65
+
66
+ const AppOverviewEmptyState = (props: AppOverviewEmptyStateProps) => (
67
+ <div class={`paper flex min-h-56 flex-col items-center justify-center p-8 text-center ${props.class ?? ""}`}>
68
+ <Show when={props.icon}>
69
+ <div class="thumbnail mb-3 flex h-12 w-12 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
70
+ <i class={`${tablerIconClass(props.icon, "ti-inbox")} text-xl text-dimmed`} />
71
+ </div>
72
+ </Show>
73
+ <h3 class="mb-1 text-sm font-semibold text-primary">{props.title}</h3>
74
+ <Show when={props.description}>
75
+ <p class="max-w-sm text-xs text-dimmed">{props.description}</p>
76
+ </Show>
77
+ {props.children}
78
+ </div>
79
+ );
80
+
81
+ const AppOverview = ((props: AppOverviewProps) => (
82
+ <div class={`mx-auto max-w-6xl p-3 sm:p-4 ${props.class ?? ""}`}>
83
+ <header class="mb-5">
84
+ <div class="flex items-center gap-3">
85
+ <div class="thumbnail flex h-11 w-11 shrink-0 items-center justify-center bg-zinc-100 dark:bg-zinc-800">
86
+ <i class={`${tablerIconClass(props.icon, "ti-apps")} text-xl text-zinc-600 dark:text-zinc-400`} />
87
+ </div>
88
+ <div class="min-w-0">
89
+ <h1 class="text-xl font-semibold text-primary">{props.title}</h1>
90
+ <Show when={props.subtitle}>
91
+ <p class="text-sm text-dimmed">{props.subtitle}</p>
92
+ </Show>
93
+ </div>
94
+ </div>
95
+ </header>
96
+
97
+ <div class="flex flex-col items-start gap-4 lg:flex-row">{props.children}</div>
98
+ </div>
99
+ )) as AppOverviewComponent;
100
+
101
+ AppOverview.Main = AppOverviewMain;
102
+ AppOverview.Aside = AppOverviewAside;
103
+ AppOverview.EmptyState = AppOverviewEmptyState;
104
+
105
+ export default AppOverview;