@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.
- package/package.json +4 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/browser.ts +1 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-tasks.ts +65 -1
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/migrate.ts +19 -2
- package/src/ai/readable-html.ts +1057 -0
- package/src/api/help.ts +23 -4
- package/src/api/mcp.ts +8 -2
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +5 -1
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/server/help.ts +136 -10
- package/src/server/services/geo.ts +21 -4
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +1 -0
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/settings/index.ts +48 -26
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/ssr/Layout.tsx +20 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/ShellRuntime.island.tsx +17 -25
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/resource-search.css +25 -0
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Lexer, type MarkedExtension, type Token, type Tokens } from "marked";
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
17
|
-
if (!hasMetadata) return token.tokens;
|
|
18
|
-
|
|
12
|
+
const headingTitleTokens = (token: Tokens.Heading): Token[] => {
|
|
19
13
|
const tokens = [...token.tokens];
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
54
|
-
const
|
|
55
|
-
const title =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
93
|
-
const
|
|
94
|
-
return `<div class="help-${kind}">${
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
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 };
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -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
|
);
|