@k2b/cloud 0.37.0 → 0.39.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 (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -1,11 +1,7 @@
1
- import type { MarkedExtension, Tokens } from "marked";
1
+ import { Lexer, type MarkedExtension, type Token, type Tokens } from "marked";
2
2
 
3
- type GuidedBlock = "compare" | "reference" | "steps";
4
-
5
- const guidedBlocks = new Set<GuidedBlock>(["compare", "reference", "steps"]);
6
- const headingMetaPattern = /\s+\{icon="([a-z0-9-]+)"\}\s*$/i;
7
-
8
- const slug = (value: string): string =>
3
+ export const headingMetaPattern = /\s+\{icon="([a-z0-9-]+)"\}\s*$/i;
4
+ export const helpHeadingSlug = (value: string): string =>
9
5
  value
10
6
  .toLocaleLowerCase()
11
7
  .normalize("NFKD")
@@ -13,63 +9,218 @@ const slug = (value: string): string =>
13
9
  .replace(/[^a-z0-9]+/g, "-")
14
10
  .replace(/^-+|-+$/g, "") || "section";
15
11
 
16
- const headingTitleTokens = (token: Tokens.Heading, hasMetadata: boolean): Tokens.Heading["tokens"] => {
17
- if (!hasMetadata) return token.tokens;
18
-
12
+ const headingTitleTokens = (token: Tokens.Heading): Token[] => {
19
13
  const tokens = [...token.tokens];
20
- const trailingToken = tokens.at(-1);
21
- if (trailingToken?.type !== "text") return tokens;
14
+ const trailing = tokens.at(-1);
15
+ if (trailing?.type === "text")
16
+ tokens[tokens.length - 1] = {
17
+ ...trailing,
18
+ raw: trailing.raw.replace(headingMetaPattern, ""),
19
+ text: trailing.text.replace(headingMetaPattern, ""),
20
+ };
21
+ return tokens;
22
+ };
22
23
 
23
- const raw = trailingToken.raw.replace(headingMetaPattern, "");
24
- const text = trailingToken.text.replace(headingMetaPattern, "");
25
- if (!raw && !text) {
26
- tokens.pop();
27
- } else {
28
- tokens[tokens.length - 1] = { ...trailingToken, raw, text };
29
- }
24
+ const FENCE = /^([ \t]*(?:>[ \t]*|(?:[-+*]|\d+[.)])[ \t]+)*)(`{3,}|~{3,})(.*)$/;
25
+ // A directive name ends at a space or the line end, as in the openers themselves: `:::reference-data` is text.
26
+ const DIRECTIVE_OPENER = /^ {0,3}:::(?:steps|reference|compare|details|note|info|success|warning|danger)(?=[ \t]|$)/i;
27
+ const DIRECTIVE_CLOSER = /^ {0,3}:::[ \t]*$/;
28
+ const CALLOUT_OPENER = /^ {0,3}:::(?:note|info|success|warning|danger)(?=[ \t]|$)/i;
29
+ const DETAILS_OPENER = /^ {0,3}:::details(?=[ \t]|$)/i;
30
30
 
31
- return tokens;
31
+ const detailsLexers = new WeakSet<Lexer>();
32
+
33
+ /** The indentation column of a line prefix; a tab advances to the next multiple of four, as in Markdown. */
34
+ const columns = (prefix: string): number => {
35
+ let column = 0;
36
+ for (const char of prefix) column = char === "\t" ? column + 4 - (column % 4) : column + 1;
37
+ return column;
32
38
  };
39
+ /** The indentation after a prefix's last quote marker. */
40
+ const indentAfterQuotes = (prefix: string): number => columns(prefix.slice(prefix.lastIndexOf(">") + 1));
33
41
 
34
42
  /**
35
- * Help-only Markdown affordances. The source stays useful as plain Markdown:
36
- * the directives merely add reading semantics to otherwise ordinary lists.
43
+ * A `:::details Title` block up to its own closing `:::`. One directive inside it (steps, reference, compare, a
44
+ * callout, which stays text there) keeps its own closer; directives do not nest further, and fenced code hides
45
+ * its lines. Details never nest; another details opener or a missing closer leaves the first opener as text.
37
46
  */
38
- export function guidedHelpExtension(): MarkedExtension {
39
- const headingIds = new Map<string, number>();
47
+ const detailsBlock = (
48
+ source: string,
49
+ start = 0,
50
+ ): { block?: { raw: string; title: string; body: string; callout: boolean }; stoppedAt: number } => {
51
+ const openerEnd = source.indexOf("\n", start);
52
+ const opener = /^:::details[ \t]+(\S[^\n]*)\n/.exec(source.slice(start, openerEnd + 1));
53
+ if (!opener) return { stoppedAt: source.length };
54
+ let offset = start + opener[0].length;
55
+ let inner = false;
56
+ let callout = false;
57
+ let listContentColumn: number | undefined;
58
+ let fence: { marker: string; prefix: string; quotes: number; list: boolean } | undefined;
59
+ while (offset < source.length) {
60
+ const newline = source.indexOf("\n", offset);
61
+ const end = newline < 0 ? source.length : newline + 1;
62
+ const line = source.slice(offset, end);
63
+ const text = line.replace(/\r?\n$/, "");
64
+ const marker = FENCE.exec(text);
65
+ const prefix = /^[ \t]*(?:>[ \t]*|(?:[-+*]|\d+[.)])[ \t]+)*/.exec(text)![0];
66
+ const quotes = prefix.split(">").length - 1;
67
+ const list = /[-+*]|\d+[.)]/.test(prefix);
68
+ const indentation = indentAfterQuotes(prefix);
69
+ if (fence && (quotes < fence.quotes || (fence.list && text.trim() && columns(/^[ \t>]*/.exec(text)![0]) < columns(fence.prefix))))
70
+ fence = undefined;
71
+ if (!fence) {
72
+ if (list) listContentColumn = indentation;
73
+ else if (text.trim() && listContentColumn !== undefined && indentation < listContentColumn) listContentColumn = undefined;
74
+ }
75
+ if (fence) {
76
+ if (
77
+ marker &&
78
+ marker[2]![0] === fence.marker[0] &&
79
+ marker[2]!.length >= fence.marker.length &&
80
+ /^[ \t]*$/.test(marker[3]!) &&
81
+ quotes === fence.quotes &&
82
+ indentAfterQuotes(marker[1]!) <= indentAfterQuotes(fence.prefix) + 3
83
+ )
84
+ fence = undefined;
85
+ } else if (marker && indentation - (listContentColumn ?? 0) <= 3 && !(marker[2]![0] === "`" && marker[3]!.includes("`")))
86
+ fence = { marker: marker[2]!, prefix: marker[1]!, quotes, list };
87
+ else if (DETAILS_OPENER.test(text)) return { stoppedAt: offset };
88
+ else if (DIRECTIVE_CLOSER.test(text)) {
89
+ if (!inner)
90
+ return {
91
+ block: {
92
+ raw: source.slice(start, end),
93
+ title: opener[1]!.trim(),
94
+ body: source.slice(start + opener[0].length, offset),
95
+ callout,
96
+ },
97
+ stoppedAt: end,
98
+ };
99
+ inner = false;
100
+ } else if (DIRECTIVE_OPENER.test(text)) {
101
+ inner = true;
102
+ callout ||= CALLOUT_OPENER.test(text);
103
+ }
104
+ offset = end;
105
+ }
106
+ return { stoppedAt: source.length };
107
+ };
40
108
 
109
+ const menuSegments = (tokens: Token[]): Token[][] => {
110
+ const segments: Token[][] = [[]];
111
+ for (const token of tokens) {
112
+ if (token.type !== "text" || !token.text.includes(" → ")) {
113
+ segments.at(-1)!.push(token);
114
+ continue;
115
+ }
116
+ const parts = token.text.split(" → ");
117
+ for (const [index, text] of parts.entries()) {
118
+ if (index > 0) segments.push([]);
119
+ segments.at(-1)!.push({ type: "text", raw: text, text });
120
+ }
121
+ }
122
+ return segments;
123
+ };
124
+
125
+ /** Help-only syntax; per-reader keys, links and heading ids are assigned after lexing. */
126
+ export function guidedHelpExtension(): MarkedExtension {
41
127
  return {
42
- hooks: {
43
- preprocess(source) {
44
- headingIds.clear();
45
- return source;
46
- },
47
- },
48
128
  renderer: {
49
- paragraph(token: Tokens.Paragraph) {
50
- if (token.tokens.length !== 1 || token.tokens[0]?.type !== "strong") return false;
51
- return `<p class="help-eyebrow">${this.parser.parseInline(token.tokens)}</p>`;
129
+ paragraph(token) {
130
+ if (token.tokens.length !== 1 || token.tokens[0]?.type !== "strong" || token.tokens[0].text.includes(" → ")) return false;
131
+ return `<p class="help-eyebrow">${this.parser.parseInline(token.tokens ?? [])}</p>`;
52
132
  },
53
- heading(token: Tokens.Heading) {
54
- const metadata = token.text.match(headingMetaPattern);
55
- const title = metadata ? token.text.slice(0, metadata.index).trim() : token.text;
56
- const baseId = slug(title);
57
- const occurrence = headingIds.get(baseId) ?? 0;
58
- headingIds.set(baseId, occurrence + 1);
59
- const id = occurrence === 0 ? baseId : `${baseId}-${occurrence + 1}`;
60
-
61
- if (token.depth === 2) {
62
- const icon = metadata?.[1] ? `ti ti-${metadata[1]}` : "ti ti-point";
63
- return `<h2 id="${id}" class="help-section-title" data-help-icon="${icon}">
64
- <span class="help-section-icon" aria-hidden="true"><i class="${icon}"></i></span>
65
- <span>${this.parser.parseInline(headingTitleTokens(token, Boolean(metadata)))}</span>
66
- </h2>`;
67
- }
68
-
69
- return `<h${token.depth} id="${id}">${this.parser.parseInline(token.tokens)}</h${token.depth}>`;
133
+ heading(token) {
134
+ const id: unknown = Reflect.get(token, "helpId");
135
+ const title = this.parser.parseInline(token.depth === 2 ? headingTitleTokens(token) : token.tokens);
136
+ return `<h${token.depth} id="${typeof id === "string" ? id : "section"}"${token.depth === 2 ? ' class="help-section-title"' : ""}>${title}</h${token.depth}>`;
137
+ },
138
+ strong(token) {
139
+ if (!token.text.includes(" → ")) return false;
140
+ const chips = menuSegments(token.tokens ?? []).map(
141
+ (segment) => `<span class="k2b-label-chip">${this.parser.parseInline(segment)}</span>`,
142
+ );
143
+ return `<span class="help-menu-path">${chips.join('<span class="help-menu-path-separator" aria-hidden="true">›</span><span class="k2b-sr-only"> → </span>')}</span>`;
70
144
  },
71
145
  },
72
146
  extensions: [
147
+ {
148
+ name: "helpKbd",
149
+ level: "inline",
150
+ start: (source) => source.indexOf(":kbd["),
151
+ tokenizer(source) {
152
+ const match = /^:kbd\[([^\]\n]*)\]/.exec(source);
153
+ return match ? { type: "helpKbd", raw: match[0], keys: match[1] } : undefined;
154
+ },
155
+ renderer: (token) => String(token.html ?? ""),
156
+ },
157
+ {
158
+ name: "helpLabel",
159
+ level: "inline",
160
+ start: (source) => source.indexOf("**"),
161
+ tokenizer(source) {
162
+ const match = /^(\*\*(?:[^*]|\*(?!\*))+?\*\*)\{icon="([a-z0-9-]+)"\}/.exec(source);
163
+ if (!match) return undefined;
164
+ const [strong] = Lexer.lexInline(match[1]!);
165
+ if (strong?.type !== "strong") return undefined;
166
+ const icon = /^\{icon="([a-z0-9-]+)"\}/.exec(source.slice(strong.raw.length));
167
+ if (!icon) return undefined;
168
+ return {
169
+ ...strong,
170
+ type: "helpLabel",
171
+ raw: strong.raw + icon[0],
172
+ icon: icon[1],
173
+ tokens: this.lexer.inlineTokens(strong.text),
174
+ };
175
+ },
176
+ renderer(token) {
177
+ const icon = `<i class="ti ti-${token.icon}" aria-hidden="true"></i>`;
178
+ const segments = menuSegments(token.tokens ?? []);
179
+ const chips = segments.map(
180
+ (segment, index) => `<span class="k2b-label-chip">${index === 0 ? icon : ""}${this.parser.parseInline(segment)}</span>`,
181
+ );
182
+ return segments.length === 1
183
+ ? chips[0]!
184
+ : `<span class="help-menu-path">${chips.join('<span class="help-menu-path-separator" aria-hidden="true">›</span><span class="k2b-sr-only"> → </span>')}</span>`;
185
+ },
186
+ childTokens: ["tokens"],
187
+ },
188
+ {
189
+ name: "helpUnresolvedLink",
190
+ level: "inline",
191
+ renderer(token) {
192
+ return this.parser.parseInline(token.tokens ?? []);
193
+ },
194
+ childTokens: ["tokens"],
195
+ },
196
+ {
197
+ name: "helpDetails",
198
+ level: "block",
199
+ tokenizer(source) {
200
+ if (detailsLexers.has(this.lexer)) return undefined;
201
+ const { block } = detailsBlock(source);
202
+ if (!block) return undefined;
203
+ detailsLexers.add(this.lexer);
204
+ try {
205
+ return {
206
+ type: "helpDetails",
207
+ raw: block.raw,
208
+ title: block.title,
209
+ // Queued like paragraph text, so a link definition below the block applies to the title too.
210
+ titleTokens: this.lexer.inline(block.title),
211
+ callout: block.callout,
212
+ // The body joins this document: its links, steps, and reference blocks work as anywhere else.
213
+ tokens: this.lexer.blockTokens(block.body, []),
214
+ };
215
+ } finally {
216
+ detailsLexers.delete(this.lexer);
217
+ }
218
+ },
219
+ renderer(token) {
220
+ return `<details class="k2b-disclosure help-details" data-surface="plain"><summary><span>${this.parser.parseInline(token.titleTokens ?? [])}</span><i class="ti ti-chevron-down k2b-disclosure__chevron" aria-hidden="true"></i></summary><div class="k2b-disclosure__content">${this.parser.parse(token.tokens ?? [])}</div></details>`;
221
+ },
222
+ childTokens: ["titleTokens", "tokens"],
223
+ },
73
224
  {
74
225
  name: "guidedHelpBlock",
75
226
  level: "block",
@@ -77,21 +228,14 @@ export function guidedHelpExtension(): MarkedExtension {
77
228
  return source.match(/^:::(?:compare|reference|steps)\b/)?.index;
78
229
  },
79
230
  tokenizer(source) {
80
- const match = source.match(/^:::(compare|reference|steps)[ \t]*\n([\s\S]*?)\n:::(?:\n|$)/);
81
- if (!match) return undefined;
82
- const kind = match[1] as GuidedBlock;
83
- if (!guidedBlocks.has(kind)) return undefined;
84
- const content = match[2]?.trim() ?? "";
85
- return {
86
- type: "guidedHelpBlock",
87
- raw: match[0],
88
- kind,
89
- tokens: this.lexer.blockTokens(content),
90
- };
231
+ const match = /^:::(compare|reference|steps)[ \t]*\n([\s\S]*?)\n:::(?:\n|$)/.exec(source);
232
+ return match
233
+ ? { type: "guidedHelpBlock", raw: match[0], kind: match[1], tokens: this.lexer.blockTokens(match[2]?.trim() ?? "") }
234
+ : undefined;
91
235
  },
92
- renderer(token: Tokens.Generic) {
93
- const kind = token.kind as GuidedBlock;
94
- return `<div class="help-${kind}">${this.parser.parse(token.tokens as Tokens.Generic[])}</div>`;
236
+ renderer(token) {
237
+ const body = this.parser.parse(token.tokens ?? []);
238
+ return `<div class="help-${token.kind}">${token.kind === "steps" ? body.replace(/<ol([^>]*) class="[^"]*"/, '<ol$1 class="k2b-steps"') : body}</div>`;
95
239
  },
96
240
  childTokens: ["tokens"],
97
241
  },
@@ -571,7 +571,7 @@ const isCurrentFormulaCell = (row: number, col: number, ctx: EvalContext): boole
571
571
  * a column that's itself filled with `=hours * rate` formulas computes
572
572
  * the chain correctly instead of seeing the literal formula strings.
573
573
  */
574
- const evaluateCell = (row: number, col: number, ctx: EvalContext): EvalResult => {
574
+ export const evaluateTableCell = (row: number, col: number, ctx: EvalContext): EvalResult => {
575
575
  const cell = cellAt(row, col, ctx);
576
576
  if (!cell.startsWith("=")) {
577
577
  const text = cell.trim();
@@ -623,7 +623,7 @@ const collectColumnValues = (arg: AST, state: EvalState): { values: string[] } |
623
623
  const values: string[] = [];
624
624
  for (let r = 0; r < state.ctx.rows.length; r++) {
625
625
  if (isCurrentFormulaCell(r, lookup.index, state.ctx)) continue;
626
- const result = evaluateCell(r, lookup.index, state.ctx);
626
+ const result = evaluateTableCell(r, lookup.index, state.ctx);
627
627
  if (result.kind === "ok") values.push(toString(result.value));
628
628
  // errored cells silently skipped — same handling as non-numeric cells
629
629
  }
@@ -790,10 +790,10 @@ const FUNCTIONS: Record<string, FuncImpl> = {
790
790
  let total = 0;
791
791
  for (let r = 0; r < state.ctx.rows.length; r++) {
792
792
  if (isCurrentFormulaCell(r, condLookup.index, state.ctx) || isCurrentFormulaCell(r, sumLookup.index, state.ctx)) continue;
793
- const condResult = evaluateCell(r, condLookup.index, state.ctx);
793
+ const condResult = evaluateTableCell(r, condLookup.index, state.ctx);
794
794
  if (condResult.kind !== "ok") continue;
795
795
  if (!isEqual(condResult.value, condValueArg.value)) continue;
796
- const sumResult = evaluateCell(r, sumLookup.index, state.ctx);
796
+ const sumResult = evaluateTableCell(r, sumLookup.index, state.ctx);
797
797
  if (sumResult.kind !== "ok") continue;
798
798
  const n = toNumber(sumResult.value);
799
799
  if (n !== null) total += n;
@@ -841,7 +841,7 @@ const FUNCTIONS: Record<string, FuncImpl> = {
841
841
  let sum = 0;
842
842
  for (let i = 0; i < row.length; i++) {
843
843
  if (i === state.ctx.currentCol) continue;
844
- const result = evaluateCell(state.ctx.currentRow, i, state.ctx);
844
+ const result = evaluateTableCell(state.ctx.currentRow, i, state.ctx);
845
845
  if (result.kind !== "ok") continue;
846
846
  const n = toNumber(result.value);
847
847
  if (n !== null) sum += n;
@@ -855,7 +855,7 @@ const FUNCTIONS: Record<string, FuncImpl> = {
855
855
  let count = 0;
856
856
  for (let i = 0; i < row.length; i++) {
857
857
  if (i === state.ctx.currentCol) continue;
858
- const result = evaluateCell(state.ctx.currentRow, i, state.ctx);
858
+ const result = evaluateTableCell(state.ctx.currentRow, i, state.ctx);
859
859
  if (result.kind !== "ok") continue;
860
860
  const n = toNumber(result.value);
861
861
  if (n !== null) {
@@ -890,7 +890,7 @@ const FUNCTIONS: Record<string, FuncImpl> = {
890
890
  }
891
891
  const rawCell = cellAt(state.ctx.currentRow, lookup.index, state.ctx);
892
892
  if (rawCell.trim().length === 0) return state.evaluate(args[1]!);
893
- return evaluateCell(state.ctx.currentRow, lookup.index, state.ctx);
893
+ return evaluateTableCell(state.ctx.currentRow, lookup.index, state.ctx);
894
894
  }
895
895
  const v = state.evaluate(args[0]!);
896
896
  if (v.kind === "error") return v;
@@ -1182,7 +1182,7 @@ const evaluateAst = (ast: AST, ctx: EvalContext): EvalResult => {
1182
1182
  lookup.suggestion,
1183
1183
  );
1184
1184
  }
1185
- return evaluateCell(ctx.currentRow, lookup.index, ctx);
1185
+ return evaluateTableCell(ctx.currentRow, lookup.index, ctx);
1186
1186
  }
1187
1187
  case "neg": {
1188
1188
  const v = evaluateAst(ast.operand, ctx);
@@ -5,13 +5,14 @@
5
5
  * the visual appearance of the CodeMirror editor extensions.
6
6
  */
7
7
 
8
- import { markdownInfoBlocks } from "@k2b/ui";
9
- import { Marked, type MarkedExtension } from "marked";
8
+ import { formatShortcut, type KeyboardPlatform, markdownInfoBlocks } from "@k2b/ui";
9
+ import { Marked, type MarkedExtension, type Token } from "marked";
10
10
  import sanitizeHtml from "sanitize-html";
11
+ import { type HelpLinkResolver, parseHelpReference } from "../help";
11
12
  import { markdownCalloutSanitizeOptions } from "./callout-sanitizer";
12
13
  import { markdownClient } from "./client";
13
14
  import { codeExtension, plainCodeExtension } from "./extensions/code";
14
- import { guidedHelpExtension } from "./extensions/guided-help";
15
+ import { guidedHelpExtension, headingMetaPattern, helpHeadingSlug } from "./extensions/guided-help";
15
16
  import { imagesExtension } from "./extensions/images";
16
17
  import { katexExtension } from "./extensions/katex";
17
18
  import { linksExtension } from "./extensions/links";
@@ -19,6 +20,7 @@ import { markExtension } from "./extensions/mark";
19
20
  import { subSupExtension } from "./extensions/sub-sup";
20
21
  import { tablesExtension } from "./extensions/tables";
21
22
  import { taskListExtension } from "./extensions/task-list";
23
+ import { escapeHtml } from "./shared";
22
24
 
23
25
  // Create a configured marked instance
24
26
  type MarkdownProfile = "content" | "help";
@@ -74,7 +76,7 @@ export type MarkdownRenderOptions = {
74
76
  locale?: string;
75
77
  };
76
78
 
77
- const sanitizeRenderedHtml = (html: string): string => {
79
+ const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content"): string => {
78
80
  const callouts = markdownCalloutSanitizeOptions();
79
81
  return sanitizeHtml(html, {
80
82
  ...callouts,
@@ -84,6 +86,7 @@ const sanitizeRenderedHtml = (html: string): string => {
84
86
  "aside",
85
87
  "br",
86
88
  "div",
89
+ ...(profile === "help" ? ["details", "summary", "kbd"] : []),
87
90
  "figcaption",
88
91
  "figure",
89
92
  "i",
@@ -115,7 +118,7 @@ const sanitizeRenderedHtml = (html: string): string => {
115
118
  "tr",
116
119
  ],
117
120
  allowedAttributes: {
118
- "*": ["aria-hidden", "aria-label", "class", "data-help-icon", "data-tone", "id", "title"],
121
+ "*": ["aria-hidden", "aria-label", "class", "data-tone", "id", "title"],
119
122
  a: [
120
123
  "href",
121
124
  "name",
@@ -128,6 +131,7 @@ const sanitizeRenderedHtml = (html: string): string => {
128
131
  annotation: ["encoding"],
129
132
  aside: [{ name: "role", multiple: false, values: ["note"] }],
130
133
  code: ["class"],
134
+ ...(profile === "help" ? { details: [{ name: "data-surface", multiple: false, values: ["plain"] }] } : {}),
131
135
  div: ["class", "data-block-name", "style"],
132
136
  img: ["alt", "class", "height", "loading", "src", "title", "width", "style"],
133
137
  input: ["checked", "class", "disabled", "type"],
@@ -210,16 +214,73 @@ export function renderMarkdownSync(content: string, options: MarkdownRenderOptio
210
214
  * Render trusted documentation Markdown with guided sections and internal
211
215
  * navigation. All fenced code remains visible source, as in ordinary Markdown.
212
216
  */
213
- export function renderHelpMarkdown(content: string, locale?: string): string {
217
+ export type HelpMarkdownRenderOptions = { locale?: string; platform?: KeyboardPlatform; links?: HelpLinkResolver };
218
+ export type { HelpLinkResolver } from "../help";
219
+
220
+ /** Lexer shared by rendering, startup validation and agent source conversion. */
221
+ export const lexHelpMarkdown = (content: string, locale?: string) => markedFor("help", "widget", locale).lexer(content);
222
+ /**
223
+ * Visit tokens in the order of marked's walkTokens. Marked concatenates every visitor result into a new array per
224
+ * token, which grows quadratically with the number of sibling tokens in one paragraph.
225
+ */
226
+ export const walkHelpTokens = (tokens: Token[], visitor: (token: Token) => void) => {
227
+ const childTokens = markedFor("help").defaults.extensions?.childTokens ?? {};
228
+ const walk = (list: readonly Token[]) => {
229
+ for (const token of list) {
230
+ visitor(token);
231
+ if (token.type === "table") {
232
+ for (const cell of token.header) walk(cell.tokens);
233
+ for (const row of token.rows) for (const cell of row) walk(cell.tokens);
234
+ } else if (token.type === "list") walk(token.items);
235
+ else if (childTokens[token.type])
236
+ for (const key of childTokens[token.type]!) walk((Reflect.get(token, key) as Token[] | Token[][]).flat(Infinity) as Token[]);
237
+ else if ("tokens" in token && token.tokens) walk(token.tokens);
238
+ }
239
+ };
240
+ walk(tokens);
241
+ };
242
+
243
+ export function renderHelpMarkdown(content: string, options: HelpMarkdownRenderOptions = {}): string {
214
244
  if (!content || typeof content !== "string") return "";
215
- const html = markedFor("help", "widget", locale).parse(content);
216
- return typeof html === "string" ? sanitizeRenderedHtml(html) : "";
245
+ const instance = markedFor("help", "widget", options.locale);
246
+ const tokens = instance.lexer(content);
247
+ const headingIds = new Map<string, number>();
248
+ walkHelpTokens(tokens, (token) => {
249
+ if (token.type === "heading") {
250
+ const base = helpHeadingSlug(token.text.replace(headingMetaPattern, "").trim());
251
+ const occurrence = headingIds.get(base) ?? 0;
252
+ headingIds.set(base, occurrence + 1);
253
+ Reflect.set(token, "helpId", occurrence === 0 ? base : `${base}-${occurrence + 1}`);
254
+ } else if (token.type === "helpKbd") {
255
+ const shortcut = formatShortcut(String(token.keys), { platform: options.platform ?? "other", locale: options.locale });
256
+ token.html = `<kbd class="k2b-kbd"><span class="k2b-sr-only">${escapeHtml(shortcut.name)}</span><span class="k2b-kbd__keys" aria-hidden="true">${shortcut.keys.map((key) => `<kbd class="k2b-kbd__key">${escapeHtml(key.label)}</kbd>`).join("")}</span></kbd>`;
257
+ } else if (token.type === "link" && token.href.startsWith("help:")) {
258
+ const ref = parseHelpReference(token.href.slice(5));
259
+ const target = ref && options.links?.(ref);
260
+ if (target) token.href = target.url + ref.fragment;
261
+ else token.type = "helpUnresolvedLink";
262
+ }
263
+ });
264
+ return sanitizeRenderedHtml(instance.parser(tokens), "help");
265
+ }
266
+
267
+ /** Derive Help search text from already-rendered HTML. */
268
+ export function helpHtmlToPlainText(html: string): string {
269
+ // The text a screen reader reads: key names instead of key caps, no decorative separators, and words of
270
+ // adjacent blocks (a callout's title and body, a summary and its details) kept apart.
271
+ const spaced = html.replace(/<\/(?:p|div|summary|li|h[1-6]|td|th)>/g, "$& ");
272
+ // Hidden parts are spans and icons; the filter only sees tags that the first pass keeps.
273
+ const spoken = sanitizeHtml(spaced, {
274
+ allowedTags: ["span", "i"],
275
+ allowedAttributes: { "*": ["aria-hidden"] },
276
+ exclusiveFilter: (frame) => frame.attribs["aria-hidden"] === "true",
277
+ });
278
+ return sanitizeHtml(spoken, { allowedTags: [], allowedAttributes: {} }).replace(/\s+/g, " ").trim();
217
279
  }
218
280
 
219
281
  /** Plain text used by lightweight client-side help search indexes. */
220
- export function markdownToPlainText(content: string): string {
221
- const html = renderHelpMarkdown(content);
222
- return sanitizeHtml(html, { allowedTags: [], allowedAttributes: {} }).replace(/\s+/g, " ").trim();
282
+ export function markdownToPlainText(content: string, locale?: string): string {
283
+ return helpHtmlToPlainText(renderHelpMarkdown(content, { locale }));
223
284
  }
224
285
 
225
286
  export { marked };
@@ -17,6 +17,18 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
17
17
  }
18
18
  };
19
19
 
20
+ /** Default landing page when no valid home path is configured. */
21
+ export const DEFAULT_HOME_PATH = "/app/dashboard";
22
+
23
+ /** Resolve a local home path, falling back for root, auth, admin, or invalid targets. */
24
+ export const resolveHomePath = (value: unknown): string => {
25
+ const path = typeof value === "string" ? normalizeRedirectTo(value) : undefined;
26
+ if (!path || path === "/" || path === "/auth" || path.startsWith("/auth/") || path === "/admin" || path.startsWith("/admin/")) {
27
+ return DEFAULT_HOME_PATH;
28
+ }
29
+ return path;
30
+ };
31
+
20
32
  /** Extract a safe post-login redirect target from an incoming request URL. */
21
33
  export const redirectPathFromRequestUrl = (requestUrl: string): string => {
22
34
  const url = new URL(requestUrl);