@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
@@ -9,13 +9,26 @@
9
9
  import type { MarkedExtension, Tokens } from "marked";
10
10
  import { escapeHtml } from "../shared";
11
11
 
12
+ /** Base64-encode a UTF-8 string for embedding in a `data-` attribute.
13
+ * Server-safe: works in Bun runtime. The matching client-side decoder
14
+ * uses `atob` + `decodeURIComponent` (see `frontend/lib/script/read-mode.ts`). */
15
+ const encodeScriptSource = (source: string): string => {
16
+ // `Buffer` exists in Bun's server-side runtime. The fallback path
17
+ // (`unescape(encodeURIComponent(...))` + `btoa`) is for any
18
+ // browser/edge environment that imports this module without Buffer.
19
+ if (typeof Buffer !== "undefined") return Buffer.from(source, "utf8").toString("base64");
20
+ return btoa(unescape(encodeURIComponent(source)));
21
+ };
22
+
12
23
  export function codeExtension(): MarkedExtension {
13
24
  return {
14
25
  renderer: {
15
26
  code(token: Tokens.Code): string {
16
27
  const { text, lang } = token;
17
28
  const escapedCode = escapeHtml(text);
18
- const isMermaid = lang?.toLowerCase() === "mermaid";
29
+ const langLower = lang?.toLowerCase();
30
+ const isMermaid = langLower === "mermaid";
31
+ const isScript = langLower === "script";
19
32
 
20
33
  // Language class for syntax highlighting / mermaid detection
21
34
  const langClass = lang ? ` language-${escapeHtml(lang)}` : "";
@@ -34,6 +47,30 @@ export function codeExtension(): MarkedExtension {
34
47
  );
35
48
  }
36
49
 
50
+ // ```script blocks: emit a wrapper carrying the source as a
51
+ // base64 `data-` attribute + an empty output container. The
52
+ // client-side `enhanceReadModeScripts` (see frontend/lib/script
53
+ // /read-mode.ts) finds these wrappers, decodes the source, and
54
+ // either runs it (when notebook.scriptsEnabled is true) or
55
+ // shows the source as a regular code block (when false).
56
+ // Decision is made client-side because the markdown layer is
57
+ // notebook-agnostic — `scriptsEnabled` is a per-notebook flag.
58
+ // The fallback (source) stays in the DOM (just `display: none`
59
+ // when scripts are active) so view-source / accessibility
60
+ // tooling sees the original code. Skip the carrier when
61
+ // there's no source — empty fences shouldn't activate.
62
+ if (isScript) {
63
+ const sourceB64 = encodeScriptSource(text);
64
+ return (
65
+ `<div class="md-script-block my-3" data-script-source="${sourceB64}">` +
66
+ `<pre class="md-script-source bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4 overflow-x-auto">` +
67
+ `<code class="text-sm font-mono text-gray-800 dark:text-gray-200 whitespace-pre language-script">${escapedCode}</code>` +
68
+ `</pre>` +
69
+ `<div class="md-script-output"></div>` +
70
+ `</div>`
71
+ );
72
+ }
73
+
37
74
  // Language badge if specified
38
75
  const langBadge = lang
39
76
  ? `<span class="absolute top-2 right-2 text-xs text-gray-400 dark:text-gray-500 font-mono select-none">${escapeHtml(lang)}</span>`
@@ -5,22 +5,58 @@
5
5
  * - Centered figure with max-height constraint
6
6
  * - Rounded border
7
7
  * - Optional caption from alt text
8
+ *
9
+ * Supports an optional Pandoc/HFM-style size suffix on the URL:
10
+ *
11
+ * ![alt](https://example.com/photo.png =400x300) // both
12
+ * ![alt](https://example.com/photo.png =400x) // width only
13
+ * ![alt](https://example.com/photo.png =x300) // height only
14
+ *
15
+ * Marked's default tokenizer treats the trailing ` =WxH` as part of the
16
+ * URL — we parse and strip it here before emitting the `<img>` tag.
8
17
  */
9
18
 
10
19
  import type { MarkedExtension, Tokens } from "marked";
11
20
  import { escapeHtml, IMAGE_STYLES } from "../shared";
12
21
 
22
+ const SIZE_SUFFIX_REGEX = /\s+=(\d+)?x(\d+)?$/;
23
+
24
+ type ParsedImage = {
25
+ href: string;
26
+ width: string | null;
27
+ height: string | null;
28
+ };
29
+
30
+ const parseImageUrl = (href: string): ParsedImage => {
31
+ const match = SIZE_SUFFIX_REGEX.exec(href);
32
+ if (!match) return { href, width: null, height: null };
33
+ return {
34
+ href: href.slice(0, match.index),
35
+ width: match[1] ?? null,
36
+ height: match[2] ?? null,
37
+ };
38
+ };
39
+
13
40
  export function imagesExtension(): MarkedExtension {
14
41
  return {
15
42
  renderer: {
16
43
  image(token: Tokens.Image): string {
17
- const { href, title, text: alt } = token;
44
+ const { href: rawHref, title, text: alt } = token;
45
+ const { href, width, height } = parseImageUrl(rawHref);
18
46
 
19
47
  // Build the image element
20
48
  const imgAttrs = [`src="${escapeHtml(href)}"`, `alt="${escapeHtml(alt || "")}"`, `loading="lazy"`, `class="${IMAGE_STYLES.img}"`];
21
49
 
22
- if (title) {
23
- imgAttrs.push(`title="${escapeHtml(title)}"`);
50
+ if (title) imgAttrs.push(`title="${escapeHtml(title)}"`);
51
+ if (width) imgAttrs.push(`width="${width}"`);
52
+ if (height) imgAttrs.push(`height="${height}"`);
53
+
54
+ // Inline style overrides the global `max-height: 400px` from
55
+ // IMAGE_STYLES.img only when the user opted into a custom size.
56
+ if (width || height) {
57
+ const styles: string[] = ["max-height: none"];
58
+ if (width) styles.push(`max-width: ${width}px`);
59
+ imgAttrs.push(`style="${styles.join("; ")}"`);
24
60
  }
25
61
 
26
62
  const imgHtml = `<img ${imgAttrs.join(" ")} />`;
@@ -18,27 +18,27 @@ const blockConfig: Record<BlockType, { icon: string; label: string; classes: str
18
18
  note: {
19
19
  icon: "ti-chevron-right",
20
20
  label: "Note",
21
- classes: "border-l-4 border-zinc-400 bg-zinc-50 dark:bg-zinc-800/50 text-zinc-800 dark:text-zinc-200",
21
+ classes: "info-block-note",
22
22
  },
23
23
  info: {
24
24
  icon: "ti-info-circle",
25
25
  label: "Info",
26
- classes: "border-l-4 border-blue-400 bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200",
26
+ classes: "info-block-info",
27
27
  },
28
28
  success: {
29
29
  icon: "ti-check",
30
30
  label: "Success",
31
- classes: "border-l-4 border-green-400 bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200",
31
+ classes: "info-block-success",
32
32
  },
33
33
  warning: {
34
34
  icon: "ti-alert-circle",
35
35
  label: "Warning",
36
- classes: "border-l-4 border-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200",
36
+ classes: "info-block-warning",
37
37
  },
38
38
  danger: {
39
39
  icon: "ti-alert-hexagon",
40
40
  label: "Danger",
41
- classes: "border-l-4 border-red-400 bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200",
41
+ classes: "info-block-danger",
42
42
  },
43
43
  };
44
44
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Mark / Highlight extension for marked.
3
+ *
4
+ * Pandoc + HFM-style highlighting:
5
+ *
6
+ * ==marked text==
7
+ *
8
+ * Renders to a `<mark>` element with a yellow textmarker-style background.
9
+ * Inner content is parsed as inline markdown so users can mix bold / italic
10
+ * inside highlights.
11
+ */
12
+
13
+ import type { MarkedExtension, Tokens } from "marked";
14
+
15
+ const MARK_CLASSES = "bg-yellow-300 dark:bg-yellow-400/40 text-zinc-900 dark:text-yellow-50 px-0.5 rounded";
16
+
17
+ export function markExtension(): MarkedExtension {
18
+ return {
19
+ extensions: [
20
+ {
21
+ name: "mark",
22
+ level: "inline",
23
+ start(src: string) {
24
+ // Hint marked at where to start scanning. Two consecutive `=` is
25
+ // cheap to find and unique enough.
26
+ return src.match(/==(?!=)/)?.index;
27
+ },
28
+ tokenizer(src: string) {
29
+ // The inner content must:
30
+ // - not start or end with whitespace (matches Pandoc behaviour)
31
+ // - not contain `=` itself (avoids overlapping with `===` etc.)
32
+ const match = /^==(?!=)([^\s=][^=]*?[^\s=]|[^\s=])==(?!=)/.exec(src);
33
+ if (!match) return undefined;
34
+ return {
35
+ type: "mark",
36
+ raw: match[0],
37
+ text: match[1] ?? "",
38
+ tokens: this.lexer.inlineTokens(match[1] ?? ""),
39
+ };
40
+ },
41
+ renderer(token: Tokens.Generic) {
42
+ const inner = this.parser.parseInline(token.tokens ?? []);
43
+ return `<mark class="${MARK_CLASSES}">${inner}</mark>`;
44
+ },
45
+ },
46
+ ],
47
+ };
48
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Subscript and Superscript extensions for marked.
3
+ *
4
+ * H~2~O → H<sub>2</sub>O
5
+ * E=mc^2^ → E=mc<sup>2</sup>
6
+ *
7
+ * Conservative tokenisers: the inner content cannot contain whitespace or
8
+ * the marker character. That avoids accidentally swallowing strikethrough
9
+ * (`~~text~~`), regular tildes used as separators, or `^` used as the
10
+ * regex caret in inline code.
11
+ */
12
+
13
+ import type { MarkedExtension, Tokens } from "marked";
14
+ import { escapeHtml } from "../shared";
15
+
16
+ export function subSupExtension(): MarkedExtension {
17
+ return {
18
+ extensions: [
19
+ {
20
+ name: "subscript",
21
+ level: "inline",
22
+ start(src: string) {
23
+ // Single `~` not preceded or followed by another `~`.
24
+ return src.match(/(?<!~)~(?!~)/)?.index;
25
+ },
26
+ tokenizer(src: string) {
27
+ const match = /^(?<!~)~(?!~)([^~\s]+)~(?!~)/.exec(src);
28
+ if (!match) return undefined;
29
+ return {
30
+ type: "subscript",
31
+ raw: match[0],
32
+ text: match[1] ?? "",
33
+ };
34
+ },
35
+ renderer(token: Tokens.Generic) {
36
+ return `<sub>${escapeHtml(String(token.text))}</sub>`;
37
+ },
38
+ },
39
+ {
40
+ name: "superscript",
41
+ level: "inline",
42
+ start(src: string) {
43
+ return src.match(/\^/)?.index;
44
+ },
45
+ tokenizer(src: string) {
46
+ const match = /^\^([^\^\s]+)\^/.exec(src);
47
+ if (!match) return undefined;
48
+ return {
49
+ type: "superscript",
50
+ raw: match[0],
51
+ text: match[1] ?? "",
52
+ };
53
+ },
54
+ renderer(token: Tokens.Generic) {
55
+ return `<sup>${escapeHtml(String(token.text))}</sup>`;
56
+ },
57
+ },
58
+ ],
59
+ };
60
+ }
@@ -1,87 +1,108 @@
1
1
  /**
2
- * Tables Extension for Marked
2
+ * Tile-style markdown table renderer for `marked`.
3
3
  *
4
- * Renders markdown tables with styling matching the CodeMirror extension.
5
- * Supports cell formatting for NULL, booleans, dates, and numbers.
4
+ * Emits the three-layer markup that `utilities-table-tile.css` styles:
5
+ * <div class="md-table-wrap"> — horizontal scroll
6
+ * <table class="md-table"> — layout reset
7
+ * <th|td>
8
+ * <span class="md-table-cell"> — visible tile
9
+ *
10
+ * Per-column alignment from `:---:` syntax flows through `token.align`
11
+ * to a `md-align-{left,center,right}` class on each cell.
12
+ *
13
+ * Cells starting with `=` are evaluated as formulas via the shared
14
+ * `formula.ts` module. The computed value replaces the cell text;
15
+ * errors render as a red-text cell with a hover-title showing the
16
+ * full diagnostic. The same evaluator runs in the editor widget so
17
+ * read-mode HTML and rich-edit preview stay in sync.
18
+ *
19
+ * Markdown tables are hand-edited and small — no pagination, no
20
+ * NULL/datetime cell-formatting, no zebra columns. If you need any of
21
+ * those, you're rendering data not prose; use the Grids app.
6
22
  */
7
-
8
23
  import type { MarkedExtension, Tokens } from "marked";
24
+ import { evaluateFormula, formatValue, isFormula, isTotalRow, parseProgressValue, type EvalContext, type ProgressValue } from "../formula";
9
25
  import { escapeHtml } from "../shared";
10
26
 
11
- const formatCell = (cell: string): string => {
12
- const trimmed = cell.trim();
27
+ type Align = "left" | "right" | "center" | null;
13
28
 
14
- // NULL
15
- if (trimmed.toLowerCase() === "null") {
16
- return '<span class="text-gray-400 italic">NULL</span>';
17
- }
29
+ const alignClass = (align: Align): string => {
30
+ if (align === "right") return " md-align-right";
31
+ if (align === "center") return " md-align-center";
32
+ return "";
33
+ };
18
34
 
19
- // Boolean
20
- if (trimmed.toLowerCase() === "true") {
21
- return '<span class="text-green-600">true</span>';
22
- }
23
- if (trimmed.toLowerCase() === "false") {
24
- return '<span class="text-red-600">false</span>';
25
- }
35
+ /** Best-effort plain-text extraction for formula evaluation. The
36
+ * formula evaluator only needs the raw cell value (numbers, strings),
37
+ * so we walk the marked-token tree and collect the text — falling
38
+ * back to `cell.text` if anything goes wrong. */
39
+ const cellText = (cell: Tokens.TableCell): string => {
40
+ if (typeof cell.text === "string" && cell.text.length > 0) return cell.text;
41
+ return "";
42
+ };
26
43
 
27
- // ISO datetime
28
- if (trimmed.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) {
29
- try {
30
- const date = new Date(trimmed);
31
- return date.toLocaleString();
32
- } catch {
33
- return escapeHtml(trimmed);
34
- }
35
- }
44
+ const renderProgressCell = (progress: ProgressValue, alignCls: string, title: string): string => {
45
+ const pct = Math.round(progress.ratio * 100);
46
+ return `<td><span class="md-table-cell md-table-progress${alignCls}" title="${escapeHtml(title)}"><span class="md-table-progress-track" aria-hidden="true"><span class="md-table-progress-fill" style="width:${pct}%"></span></span><span>${escapeHtml(progress.label)}</span></span></td>`;
47
+ };
36
48
 
37
- // Number with decimals
38
- const num = parseFloat(trimmed);
39
- if (!isNaN(num) && trimmed.includes(".") && !Number.isInteger(num)) {
40
- return num.toFixed(4).replace(/\.?0+$/, "");
49
+ const renderCell = (alignCls: string, originalText: string, ctx: EvalContext, htmlContent: string): string => {
50
+ if (!isFormula(originalText)) {
51
+ return `<td><span class="md-table-cell${alignCls}">${htmlContent}</span></td>`;
41
52
  }
42
-
43
- return escapeHtml(trimmed);
53
+ const result = evaluateFormula(originalText, ctx);
54
+ if (result.kind === "ok") {
55
+ const progress = parseProgressValue(result.value);
56
+ if (progress) return renderProgressCell(progress, alignCls, originalText);
57
+ // Computed cells get a `ti-math-function` icon prefix + blue text
58
+ // so the user can tell at a glance which values are derived vs
59
+ // hand-typed. Hover-title shows the original formula source.
60
+ return `<td><span class="md-table-cell md-formula-ok${alignCls}" title="${escapeHtml(originalText)}"><i class="ti ti-math-function"></i>${escapeHtml(formatValue(result.value))}</span></td>`;
61
+ }
62
+ // Error: show the original formula in red + ⚠ icon, hover title
63
+ // carries the full diagnostic. Pattern matches `md-formula-error`
64
+ // styling in `utilities-table-tile.css`.
65
+ const tooltip = result.suggestion ? `${result.message}\n→ Suggestion: ${result.suggestion}` : result.message;
66
+ return `<td><span class="md-table-cell md-formula-error${alignCls}" title="${escapeHtml(tooltip)}">⚠ ${escapeHtml(originalText)}</span></td>`;
44
67
  };
45
68
 
46
69
  export function tablesExtension(): MarkedExtension {
47
70
  return {
48
71
  renderer: {
49
72
  table(token: Tokens.Table): string {
73
+ const align = token.align ?? [];
74
+
75
+ // Build the EvalContext once per table — formulas reference
76
+ // raw cell text, not other formulas' results, so a single pass
77
+ // through the source rows is enough.
78
+ const headers = token.header.map((h) => cellText(h));
79
+ const rawRows = token.rows.map((row) => row.map((c) => cellText(c)));
80
+
50
81
  const headerCells = token.header
51
- .map(
52
- (cell) =>
53
- `<th class="px-3 py-2 text-left font-medium bg-gray-100 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 whitespace-nowrap min-w-30">${this.parser.parseInline(
54
- cell.tokens,
55
- )}</th>`,
56
- )
82
+ .map((cell, i) => {
83
+ const cls = `md-table-cell${alignClass(align[i] ?? null)}`;
84
+ return `<th><span class="${cls}">${this.parser.parseInline(cell.tokens)}</span></th>`;
85
+ })
57
86
  .join("");
58
87
 
59
88
  const rows = token.rows
60
- .map((row) => {
89
+ .map((row, rowIdx) => {
90
+ const rowTexts = rawRows[rowIdx] ?? [];
91
+ const totalRow = isTotalRow(rowTexts);
61
92
  const cells = row
62
- .map((cell, i) => {
63
- const content = this.parser.parseInline(cell.tokens);
64
- const bgClass = i % 2 === 0 ? "bg-gray-50 dark:bg-gray-800/20" : "";
65
- return `<td class="px-3 py-2 whitespace-nowrap min-w-30 ${bgClass}">${formatCell(content)}</td>`;
93
+ .map((cell, colIdx) => {
94
+ const alignCls = alignClass(align[colIdx] ?? null);
95
+ const original = cellText(cell);
96
+ const htmlContent = this.parser.parseInline(cell.tokens);
97
+ const ctx: EvalContext = { headers, rows: rawRows, currentRow: rowIdx, currentCol: colIdx };
98
+ return renderCell(alignCls, original, ctx, htmlContent);
66
99
  })
67
100
  .join("");
68
- return `<tr class="hover:font-semibold">${cells}</tr>`;
101
+ return totalRow ? `<tr class="md-table-total-row">${cells}</tr>` : `<tr>${cells}</tr>`;
69
102
  })
70
- .join("\n");
71
-
72
- const rowCount = token.rows.length;
103
+ .join("");
73
104
 
74
- return `<div class="cm-table-widget my-2">
75
- <div class="flex flex-col">
76
- <div class="overflow-x-auto rounded">
77
- <table class="min-w-full text-sm tabular-nums">
78
- <thead><tr>${headerCells}</tr></thead>
79
- <tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
80
- </table>
81
- </div>
82
- <div class="text-center text-xs mt-2">${rowCount} row${rowCount === 1 ? "" : "s"}</div>
83
- </div>
84
- </div>`;
105
+ return `<div class="md-table-wrap"><table class="md-table"><thead><tr>${headerCells}</tr></thead><tbody>${rows}</tbody></table></div>`;
85
106
  },
86
107
  },
87
108
  };