@k2b/cloud 0.37.0 → 0.38.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 (64) hide show
  1. package/package.json +4 -2
  2. package/src/_internal/help-catalog.ts +123 -4
  3. package/src/_internal/help.ts +27 -6
  4. package/src/account/Avatar.tsx +20 -2
  5. package/src/account/ui.ts +1 -1
  6. package/src/ai/browser.ts +1 -0
  7. package/src/ai/chart-block.ts +44 -13
  8. package/src/ai/chat/chart-block.tsx +2 -11
  9. package/src/ai/chat/messages.ts +10 -0
  10. package/src/ai/chat/web-tools.tsx +23 -6
  11. package/src/ai/chat-tasks.ts +65 -1
  12. package/src/ai/default-tool-contracts.ts +30 -13
  13. package/src/ai/fetch-file-tool.ts +37 -3
  14. package/src/ai/migrate.ts +19 -2
  15. package/src/ai/readable-html.ts +1057 -0
  16. package/src/api/help.ts +23 -4
  17. package/src/api/mcp.ts +8 -2
  18. package/src/browser/FileChooser.tsx +11 -3
  19. package/src/browser/choose-files.browser-harness.tsx +4 -1
  20. package/src/browser/file-chooser-messages.ts +4 -0
  21. package/src/browser/file-providers.ts +14 -1
  22. package/src/browser/provider-folder.tsx +58 -5
  23. package/src/browser/resource-picker.tsx +5 -1
  24. package/src/cli/admin/workflows.ts +1 -0
  25. package/src/config/env.ts +14 -0
  26. package/src/contracts/file-provider.ts +48 -1
  27. package/src/contracts/outgoing-mail.ts +8 -1
  28. package/src/server/help.ts +136 -10
  29. package/src/server/services/geo.ts +21 -4
  30. package/src/services/allowed-network-fetch.ts +189 -0
  31. package/src/services/help/index.ts +46 -5
  32. package/src/services/help/store.ts +26 -5
  33. package/src/services/help/types.ts +10 -2
  34. package/src/services/index.ts +1 -0
  35. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  36. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  37. package/src/services/outgoing-mail/test-send.ts +18 -3
  38. package/src/services/outgoing-mail/transport.ts +24 -13
  39. package/src/services/settings/index.ts +48 -26
  40. package/src/shared/help.ts +41 -0
  41. package/src/shared/index.ts +9 -1
  42. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  43. package/src/shared/markdown/formula.ts +8 -8
  44. package/src/shared/markdown/index.ts +72 -11
  45. package/src/ssr/Layout.tsx +20 -2
  46. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  47. package/src/ssr/LayoutHelp.tsx +569 -410
  48. package/src/ssr/ShellRuntime.island.tsx +17 -25
  49. package/src/ssr/help-messages.ts +60 -38
  50. package/src/ssr/help.ts +3 -0
  51. package/src/ssr/layout-help-article.ts +41 -0
  52. package/src/ssr/layout-help-navigation.ts +0 -12
  53. package/src/ssr/layout-help-registry.tsx +4 -11
  54. package/src/ssr/layout-help-search.ts +9 -12
  55. package/src/ssr/layout-help-start.ts +68 -0
  56. package/src/ssr/platform-messages.ts +4 -0
  57. package/src/ssr/shell-data.tsx +7 -0
  58. package/src/ssr/shell-shortcuts.ts +8 -0
  59. package/src/styles/effects.css +688 -185
  60. package/src/styles/resource-search.css +25 -0
  61. package/src/workflows/store/events.ts +24 -0
  62. package/src/workflows/store/index.ts +8 -0
  63. package/src/workflows/store/observability.ts +6 -1
  64. 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 };
@@ -1,5 +1,5 @@
1
1
  import type { DateContext } from "@k2b/stdlib";
2
- import { AppWorkspace, appWorkspaceLayoutStyle, LocaleProvider, NoticeCard, PageTrailProvider } from "@k2b/ui";
2
+ import { AppWorkspace, appWorkspaceLayoutStyle, keyboardPlatform, LocaleProvider, NoticeCard, PageTrailProvider } from "@k2b/ui";
3
3
  import type { JSX } from "solid-js/jsx-runtime";
4
4
  import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
5
5
  import { readShellFocusCookie } from "../_internal/shell-focus";
@@ -14,11 +14,12 @@ import { readThemePreferenceFromCookieHeader } from "../shared/theme";
14
14
  import AnnouncementBanners from "./AnnouncementBanners";
15
15
  import { appAppearanceStyle, resolveCurrentApp } from "./app-appearance";
16
16
  import { resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
17
- import { getLayoutHelp } from "./help";
17
+ import { getLayoutHelp, layoutHelpFailed } from "./help";
18
18
  import LayoutFooter from "./LayoutFooter";
19
19
  import LayoutHeader from "./LayoutHeader";
20
20
  import LayoutRail, { type LayoutAppLink } from "./LayoutRail";
21
21
  import type { LayoutContext } from "./layout-context";
22
+ import { HELP_VIEW_LIMIT } from "./layout-help-start";
22
23
  import type { LayoutBreadcrumb } from "./layout-runtime";
23
24
  import PageTrailRow from "./PageTrailRow";
24
25
  import { derivePageTrail } from "./page-trail";
@@ -36,10 +37,14 @@ type LayoutProps = {
36
37
  children: JSX.Element;
37
38
  c: LayoutContext;
38
39
  title?: string | Breadcrumb[];
40
+ /** Up to three ids of the current app's Help articles that fit this page; Help lists them first under "For this view". Unknown ids and articles the reader may not see are ignored. */
41
+ help?: readonly string[];
39
42
  fullPage?: boolean /** Keep the shell viewport-bound and suppress its footer. */;
40
43
  fullWidth?: boolean /** Delegate scrolling and clipping to the page's work surface. */;
41
44
  focusMode?: boolean /** Render only the app canvas and content for dedicated editors or pop-out windows. */;
42
45
  flushCanvas?: boolean /** Remove app-canvas spacing for edge-to-edge focus surfaces such as pop-out windows. */;
46
+ /** Final localized app-specific export or printing guidance, shown when no data-print-region exists. */
47
+ printHint?: string;
43
48
  workspaceSidebarCollapsible?: boolean /** Resolve persisted sidebar geometry for the page's sidebar policy during SSR. */;
44
49
  }; // ==========================
45
50
  // Helpers
@@ -206,7 +211,12 @@ export default function Layout(props: LayoutProps) {
206
211
  .filter(Boolean)
207
212
  .join(";") || undefined;
208
213
  const appLabel = currentApp?.name ?? (pathname.startsWith("/admin") ? t.admin : undefined);
214
+ const helpIds = new Set(registeredHelp?.documents.map((topic) => topic.id));
215
+ const viewHelp = [...new Set(props.help)].filter((id) => helpIds.has(id)).slice(0, HELP_VIEW_LIMIT);
209
216
  const shellData: ShellData = {
217
+ platform: keyboardPlatform(c.req.raw.headers.get("User-Agent") ?? ""),
218
+ ...(viewHelp.length ? { help: viewHelp } : {}),
219
+ ...(layoutHelpFailed(c) ? { helpFailed: true as const } : {}),
210
220
  apps: allApps,
211
221
  ...(user
212
222
  ? { rail: c.get("railPreferences") ?? defaultRailPreferences(), profile: { name: profileName, avatarSrc: profileAvatarSrc, appHref } }
@@ -235,6 +245,10 @@ export default function Layout(props: LayoutProps) {
235
245
  <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
236
246
  </main>
237
247
  </PageTrailProvider>
248
+ <div data-print-fallback="">
249
+ <p>{t.printFallbackTitle}</p>
250
+ <p>{props.printHint ?? t.printFallbackHint}</p>
251
+ </div>
238
252
  </div>
239
253
  </LocaleProvider>
240
254
  );
@@ -292,6 +306,10 @@ export default function Layout(props: LayoutProps) {
292
306
  {!fullPage && !showRail && <LayoutFooter appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />}
293
307
  </div>
294
308
  </ShellDataProvider>
309
+ <div data-print-fallback="">
310
+ <p>{t.printFallbackTitle}</p>
311
+ <p>{props.printHint ?? t.printFallbackHint}</p>
312
+ </div>
295
313
  </div>
296
314
  </LocaleProvider>
297
315
  );