@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,6 +1,6 @@
1
1
  import type { AppRegistryHelpSummary } from "../../contracts/registry";
2
2
  import type { RequestActor } from "../../contracts/shared";
3
- import type { HelpDocumentManifest } from "../../shared/help";
3
+ import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpKind } from "../../shared/help";
4
4
 
5
5
  export type HelpDocument = {
6
6
  id: string;
@@ -8,6 +8,10 @@ export type HelpDocument = {
8
8
  icon?: string;
9
9
  description?: string;
10
10
  order: number;
11
+ kind: HelpKind;
12
+ needs?: HelpAccessLevel;
13
+ next?: string;
14
+ related: readonly string[];
11
15
  markdown: string;
12
16
  searchText: string;
13
17
  };
@@ -30,8 +34,12 @@ export type HelpMetadata = {
30
34
  description?: string;
31
35
  icon?: string;
32
36
  order: number;
37
+ kind: HelpKind;
38
+ needs?: HelpAccessLevel;
39
+ next?: string;
40
+ related: readonly string[];
33
41
  };
34
- export type HelpArticle = HelpMetadata & { markdown: string };
42
+ export type HelpArticle = HelpMetadata & { markdown: string; links: Readonly<Record<string, HelpArticleLink>> };
35
43
  export type HelpMatch = Pick<HelpMetadata, "appId" | "appName" | "locale" | "documentId" | "title" | "description"> & { kind: "help" };
36
44
  export type HelpSearchInput = { query: string; appId?: string; limit?: number };
37
45
  export type HelpReadInput = { appId: string; documentId: string };
@@ -8,6 +8,7 @@ export { accountIdentities, AccountIdentityError } from "./accounts/identities";
8
8
  export type { AccountIdentityUser, AccountIdentityGroup, AccountIdentityPage, AccountIdentityAvailability } from "./accounts/identities";
9
9
  export type { AccountIdentityReconciliation } from "./accounts/identity-reconciliation";
10
10
  export { providers } from "./providers";
11
+ export { createAllowedNetworkFetch } from "./allowed-network-fetch";
11
12
  export { requestPublicHttps, type PublicHttpInput } from "./public-http";
12
13
  export { authFlows } from "./auth-flows";
13
14
  export { toPgIntArray, toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
@@ -12,7 +12,7 @@ import { allowedMailProfile, type MessageRow, messageAttachmentRefs, messageReco
12
12
  import { OutgoingMailError, resolveMailCredentials } from "./store";
13
13
  import { mailAttachments, mailSettled } from "./sync";
14
14
  import { smtpFailureMessage } from "./test-send";
15
- import { buildMailTransport } from "./transport";
15
+ import { resolveMailTransport } from "./transport";
16
16
 
17
17
  export const cleanupMailAttachments = async (id: string): Promise<void> => {
18
18
  const row = await outgoingMailMessages.read(id);
@@ -136,7 +136,12 @@ export const attemptOutgoingMail = async (row: MessageRow, signal?: AbortSignal)
136
136
  socket.once("connect", () => {
137
137
  if (attempt.signal.aborted) socket.destroy();
138
138
  });
139
- const transport = buildMailTransport(credentials, socket);
139
+ const resolved = await resolveMailTransport(credentials, attempt.signal).catch((error: unknown) => {
140
+ socket.destroy();
141
+ throw error;
142
+ });
143
+ // Nodemailer cannot fall back across IP literals; each queued retry rotates to the next checked address.
144
+ const transport = resolved.build(Math.max(0, row.attempt_count - 1), socket);
140
145
  const stop = () => {
141
146
  socket.destroy();
142
147
  transport.close();
@@ -0,0 +1,39 @@
1
+ import { env } from "../../config/env";
2
+ import { networkAddressFamily, normalizeNetworkHostname, parseNetworkAllowlistEnv } from "../../shared/network-address";
3
+ import { NetworkTargetBlockedError, resolveAllowedNetworkAddresses } from "../network-security";
4
+
5
+ export const outgoingMailAllowlist = () => parseNetworkAllowlistEnv("OUTGOING_MAIL_ALLOWED_TARGETS", env.OUTGOING_MAIL_ALLOWED_TARGETS);
6
+
7
+ /** One bounded lookup shared by SMTP and bounce IMAP; clients use only the checked addresses. */
8
+ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses) => {
9
+ const hostname = normalizeNetworkHostname(host);
10
+ const deadline = new AbortController();
11
+ const timer = setTimeout(() => deadline.abort(new Error("Outgoing mail DNS lookup timed out.")), 5000);
12
+ const cancellation = signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal;
13
+ let abort: (() => void) | undefined;
14
+ try {
15
+ cancellation.throwIfAborted();
16
+ const addresses = await Promise.race([
17
+ resolve(hostname, outgoingMailAllowlist()),
18
+ new Promise<never>((_, reject) => {
19
+ abort = () => reject(cancellation.reason);
20
+ cancellation.addEventListener("abort", abort, { once: true });
21
+ if (cancellation.aborted) abort();
22
+ }),
23
+ ]);
24
+ return {
25
+ host: hostname,
26
+ addresses: [...addresses.filter((entry) => entry.family === 4), ...addresses.filter((entry) => entry.family === 6)],
27
+ servername: networkAddressFamily(hostname) ? undefined : hostname,
28
+ };
29
+ } catch (error) {
30
+ if (error instanceof NetworkTargetBlockedError)
31
+ throw new NetworkTargetBlockedError(
32
+ "Outgoing mail target is on an internal network. An operator can allow it with OUTGOING_MAIL_ALLOWED_TARGETS.",
33
+ );
34
+ throw error;
35
+ } finally {
36
+ clearTimeout(timer);
37
+ if (abort) cancellation.removeEventListener("abort", abort);
38
+ }
39
+ };
@@ -1,7 +1,7 @@
1
1
  import { audit } from "../audit";
2
2
  import { get } from "../settings";
3
3
  import { type MailAuditContext, OutgoingMailError, resolveMailCredentials } from "./store";
4
- import { buildMailTransport } from "./transport";
4
+ import { resolveMailTransport } from "./transport";
5
5
 
6
6
  export const smtpFailureMessage = (error: unknown, secrets: readonly (string | null)[]): string => {
7
7
  let message = error instanceof Error ? error.message : "SMTP delivery failed.";
@@ -17,8 +17,23 @@ export const outgoingMailTest = {
17
17
  const event = { ...context, action: "outgoing_mail.profile.test", target: { type: "outgoing_mail_profile", id: key } };
18
18
  // Fail before the external effect if audit storage is unavailable.
19
19
  await audit.record({ ...event, outcome: "allowed" });
20
- const transport = buildMailTransport(profile);
20
+ let transport: ReturnType<Awaited<ReturnType<typeof resolveMailTransport>>["build"]> | undefined;
21
21
  try {
22
+ const resolved = await resolveMailTransport(profile);
23
+ for (let index = 0; index < resolved.count; index++) {
24
+ transport = resolved.build(index);
25
+ try {
26
+ // CONN also labels socket errors after DATA. Only fall back during a
27
+ // preflight that cannot send a message, then send once to that address.
28
+ await transport.verify();
29
+ break;
30
+ } catch (error) {
31
+ transport.close();
32
+ transport = undefined;
33
+ if (!(error instanceof Error) || !("command" in error) || error.command !== "CONN" || index === resolved.count - 1) throw error;
34
+ }
35
+ }
36
+ if (!transport) throw new Error("No validated SMTP address.");
22
37
  await transport.sendMail({
23
38
  from: { address: profile.fromAddress, name: profile.fromName ?? (await get<string>("app.name")) },
24
39
  to: recipient,
@@ -34,7 +49,7 @@ export const outgoingMailTest = {
34
49
  await audit.recordResultAfterSideEffect({ ...event, result: { ok: false, error: { code: "smtp_failed", message, status: 500 } } });
35
50
  throw new OutgoingMailError("smtp_failed", message, 502);
36
51
  } finally {
37
- transport.close();
52
+ transport?.close();
38
53
  }
39
54
  },
40
55
  };
@@ -1,17 +1,28 @@
1
1
  import type { Socket } from "node:net";
2
2
  import { createTransport } from "nodemailer";
3
+ import { resolveMailHost } from "./resolve-host";
3
4
  import type { resolveMailCredentials } from "./store";
4
5
 
5
- export const buildMailTransport = (profile: Awaited<ReturnType<typeof resolveMailCredentials>>, socket?: Socket) =>
6
- createTransport({
7
- // Workers own the raw socket so timeout/shutdown can destroy it. Nodemailer
8
- // still owns SMTP and upgrades this socket for implicit TLS or STARTTLS.
9
- ...(socket ? { socket } : {}),
10
- host: profile.smtpHost,
11
- port: profile.smtpPort,
12
- secure: profile.smtpSecure,
13
- ...(profile.smtpUser ? { auth: { user: profile.smtpUser, pass: profile.smtpPassword ?? "" } } : {}),
14
- connectionTimeout: 10000,
15
- greetingTimeout: 10000,
16
- socketTimeout: 60000,
17
- });
6
+ export const resolveMailTransport = async (profile: Awaited<ReturnType<typeof resolveMailCredentials>>, signal?: AbortSignal) => {
7
+ const target = await resolveMailHost(profile.smtpHost, signal);
8
+ signal?.throwIfAborted();
9
+ const count = target.addresses.length;
10
+ return {
11
+ count,
12
+ build: (index: number, socket?: Socket) =>
13
+ createTransport({
14
+ // Workers own the raw socket so timeout/shutdown can destroy it. Nodemailer
15
+ // still owns SMTP and upgrades this socket for implicit TLS or STARTTLS.
16
+ ...(socket ? { socket } : {}),
17
+ host: target.addresses[Math.max(0, index) % count]!.address,
18
+ servername: target.servername,
19
+ tls: { servername: target.servername },
20
+ port: profile.smtpPort,
21
+ secure: profile.smtpSecure,
22
+ ...(profile.smtpUser ? { auth: { user: profile.smtpUser, pass: profile.smtpPassword ?? "" } } : {}),
23
+ connectionTimeout: 10000,
24
+ greetingTimeout: 10000,
25
+ socketTimeout: 60000,
26
+ }),
27
+ };
28
+ };
@@ -6,7 +6,8 @@
6
6
  * Custom DB values are encrypted at rest using APP_SECRET. All read/write
7
7
  * goes through the Redis cache-aside layer in `./store.ts` (5-minute TTL).
8
8
  * `loadCache()` runs once at boot to normalize legacy rows and bootstrap
9
- * env-backed entries into Postgres.
9
+ * env-backed entries into Postgres. Startup never replaces an existing row;
10
+ * env bootstrap only fills missing keys.
10
11
  */
11
12
 
12
13
  import { redis, sql } from "bun";
@@ -17,11 +18,12 @@ import { bulkRead, deleteKey, readKey, writeKey } from "./store";
17
18
  type SqlClient = typeof sql;
18
19
 
19
20
  type StoredRow = { key: string; value: string };
20
- type PendingRow = { key: string; value: unknown; rewrite: boolean; existed: boolean };
21
+ type PendingRow = { value: unknown; originalCiphertext: string };
21
22
  type NormalizationStats = {
22
23
  encryptedLoaded: number;
23
24
  normalizedUpdated: number;
24
25
  envBootstrapped: number;
26
+ undecryptable: number;
25
27
  invalidSkipped: number;
26
28
  };
27
29
 
@@ -58,23 +60,18 @@ const shouldBootstrapValue = (def: SettingDef, value: unknown): boolean => {
58
60
  }
59
61
  };
60
62
 
61
- const upsertEncryptedRow = async (key: string, value: unknown): Promise<void> => {
62
- const encryptedValue = await encryptValue(value);
63
- await sql`
64
- INSERT INTO settings.entries (key, value, updated_at)
65
- VALUES (${key}, ${encryptedValue}, now())
66
- ON CONFLICT (key)
67
- DO UPDATE SET value = ${encryptedValue}, updated_at = now()
68
- `;
69
- await redis.del(REDIS_KEY(key));
70
- };
71
-
72
- const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; value: unknown }>; stats: NormalizationStats }> => {
63
+ const normalizeStoredEntries = async (): Promise<{
64
+ rows: Array<{ key: string; value: unknown }>;
65
+ storedKeys: Set<string>;
66
+ stats: NormalizationStats;
67
+ }> => {
73
68
  const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries`;
69
+ const storedKeys = new Set(rows.map((row) => row.key));
74
70
  const stats: NormalizationStats = {
75
71
  encryptedLoaded: 0,
76
72
  normalizedUpdated: 0,
77
73
  envBootstrapped: 0,
74
+ undecryptable: 0,
78
75
  invalidSkipped: 0,
79
76
  };
80
77
  const pendingRows = new Map<string, PendingRow>();
@@ -82,11 +79,17 @@ const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; va
82
79
  for (const row of rows) {
83
80
  try {
84
81
  const value = await decryptValue(row.value);
85
- pendingRows.set(row.key, { key: row.key, value, rewrite: false, existed: true });
82
+ pendingRows.set(row.key, { value, originalCiphertext: row.value });
86
83
  stats.encryptedLoaded += 1;
87
84
  } catch {
88
- console.warn(`[settings] skipping invalid stored value for "${row.key}"`);
89
- stats.invalidSkipped += 1;
85
+ // An unreadable value is not a missing value (#1295). Usually this
86
+ // process runs with another APP_SECRET than the writer; applications
87
+ // with the right key still read this row. Keep its key in `storedKeys`
88
+ // so env bootstrap never replaces it, and never log the value.
89
+ console.warn(
90
+ `[settings] cannot decrypt stored value for "${row.key}" with this APP_SECRET; keeping the row unchanged, reads use the environment fallback or default`,
91
+ );
92
+ stats.undecryptable += 1;
90
93
  }
91
94
  }
92
95
 
@@ -109,28 +112,47 @@ const normalizeStoredEntries = async (): Promise<{ rows: Array<{ key: string; va
109
112
 
110
113
  normalized.push({ key, value: validated.value });
111
114
 
112
- if (row.rewrite || !row.existed || !isEqual(row.value, validated.value)) {
113
- await upsertEncryptedRow(key, validated.value);
114
- stats.normalizedUpdated += 1;
115
+ if (!isEqual(row.value, validated.value)) {
116
+ const encryptedValue = await encryptValue(validated.value);
117
+ const updated = await sql<{ key: string }[]>`
118
+ UPDATE settings.entries SET value = ${encryptedValue}, updated_at = now()
119
+ WHERE key = ${key} AND value = ${row.originalCiphertext}
120
+ RETURNING key
121
+ `;
122
+ if (updated.length > 0) {
123
+ await redis.del(REDIS_KEY(key));
124
+ stats.normalizedUpdated += 1;
125
+ }
115
126
  }
116
127
  }
117
128
 
118
- return { rows: normalized, stats };
129
+ return { rows: normalized, storedKeys, stats };
119
130
  };
120
131
 
121
132
  const bootstrapEnvBackedEntries = async (config: {
122
133
  rows: Array<{ key: string; value: unknown }>;
134
+ storedKeys: Set<string>;
123
135
  stats: NormalizationStats;
124
136
  }): Promise<Array<{ key: string; value: unknown }>> => {
125
137
  const rowsByKey = new Map(config.rows.map((row) => [row.key, row.value]));
126
138
 
127
139
  for (const def of SETTINGS) {
128
- if (rowsByKey.has(def.key)) continue;
140
+ // Existing rows block bootstrap even if unreadable, unknown, or invalid.
141
+ if (config.storedKeys.has(def.key)) continue;
129
142
 
130
143
  const envValue = resolveEnvValue(def, "bootstrap");
131
144
  if (!shouldBootstrapValue(def, envValue)) continue;
132
145
 
133
- await upsertEncryptedRow(def.key, envValue);
146
+ const encryptedValue = await encryptValue(envValue);
147
+ const inserted = await sql<{ key: string }[]>`
148
+ INSERT INTO settings.entries (key, value, updated_at)
149
+ VALUES (${def.key}, ${encryptedValue}, now())
150
+ ON CONFLICT (key) DO NOTHING
151
+ RETURNING key
152
+ `;
153
+ if (inserted.length === 0) continue;
154
+
155
+ await redis.del(REDIS_KEY(def.key));
134
156
  rowsByKey.set(def.key, envValue);
135
157
  config.stats.envBootstrapped += 1;
136
158
  }
@@ -145,11 +167,11 @@ const bootstrapEnvBackedEntries = async (config: {
145
167
  */
146
168
  export async function loadCache(): Promise<void> {
147
169
  getAppSecret();
148
- const { rows, stats } = await normalizeStoredEntries();
149
- const bootstrappedRows = await bootstrapEnvBackedEntries({ rows, stats });
170
+ const { rows, storedKeys, stats } = await normalizeStoredEntries();
171
+ const bootstrappedRows = await bootstrapEnvBackedEntries({ rows, storedKeys, stats });
150
172
 
151
173
  console.log(
152
- `[settings] loaded ${bootstrappedRows.length} custom setting(s) (${stats.encryptedLoaded} encrypted, ${stats.normalizedUpdated} normalized, ${stats.envBootstrapped} env-bootstrapped, ${stats.invalidSkipped} skipped)`,
174
+ `[settings] loaded ${bootstrappedRows.length} custom setting(s) (${stats.encryptedLoaded} encrypted, ${stats.normalizedUpdated} normalized, ${stats.envBootstrapped} env-bootstrapped, ${stats.undecryptable} undecryptable, ${stats.invalidSkipped} skipped)`,
153
175
  );
154
176
  }
155
177
 
@@ -1,5 +1,39 @@
1
1
  import { localeFallbackChain } from "./locale";
2
2
 
3
+ export const HELP_KINDS = ["start", "task", "reference", "troubleshoot"] as const;
4
+ export type HelpKind = (typeof HELP_KINDS)[number];
5
+ /** The glossary's access levels; Help names only these, never app-specific permissions. */
6
+ export const HELP_ACCESS_LEVELS = ["view", "edit", "manage"] as const;
7
+ export type HelpAccessLevel = (typeof HELP_ACCESS_LEVELS)[number];
8
+ /** An article another article points to, resolved for the current reader. */
9
+ export type HelpArticleLink = {
10
+ appId: string;
11
+ appName: string;
12
+ /** The target app's accent colour (registry appearance), when it declares one. */
13
+ accent?: string;
14
+ documentId: string;
15
+ title: string;
16
+ description?: string;
17
+ icon?: string;
18
+ /** Canonical page path: `/help/apps/<appId>/<documentId>`. */
19
+ url: string;
20
+ };
21
+
22
+ export type HelpLinkResolver = (ref: { appId?: string; documentId: string }) => HelpArticleLink | undefined;
23
+
24
+ /** References have the same app-id grammar as capability declarations. */
25
+ export const HELP_REFERENCE_PATTERN = /^(?:[a-z][a-z0-9-]*\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
26
+ export const parseHelpReference = (value: string): { appId?: string; documentId: string; fragment: string } | undefined => {
27
+ const [reference = "", ...fragments] = value.split("#");
28
+ if (!HELP_REFERENCE_PATTERN.test(reference)) return undefined;
29
+ const parts = reference.split("/");
30
+ return {
31
+ appId: parts.length === 2 ? parts[0] : undefined,
32
+ documentId: parts.at(-1)!,
33
+ fragment: fragments.length ? `#${fragments.join("#")}` : "",
34
+ };
35
+ };
36
+
3
37
  export type HelpDocumentManifest = {
4
38
  /** Resolved content locale on central Help manifests. */
5
39
  locale?: string;
@@ -8,6 +42,8 @@ export type HelpDocumentManifest = {
8
42
  icon?: string;
9
43
  description?: string;
10
44
  order: number;
45
+ /** Where the Help start lists the article; missing means `task`. */
46
+ kind?: HelpKind;
11
47
  /** Core-owned endpoint used for debounced full-text search. */
12
48
  searchUrl: string;
13
49
  url: string;
@@ -24,8 +60,13 @@ export type HelpDocumentPayload = {
24
60
  locale: string;
25
61
  id: string;
26
62
  title: string;
63
+ /** Agent text with Help-only presentation converted to portable Markdown. */
27
64
  markdown: string;
28
65
  html: string;
66
+ kind: HelpKind;
67
+ needs?: HelpAccessLevel;
68
+ next?: HelpArticleLink;
69
+ related: HelpArticleLink[];
29
70
  };
30
71
 
31
72
  /** Exact locale, then BCP 47 ancestors, then the corpus base locale. */
@@ -46,7 +46,15 @@ export * from "./login-method";
46
46
  export { markdown } from "./markdown";
47
47
  export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
48
48
  export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
49
- export { createProgressValue, evaluateFormula, formatValue, isFormula, isTotalRow, parseProgressValue } from "./markdown/formula";
49
+ export {
50
+ createProgressValue,
51
+ evaluateFormula,
52
+ evaluateTableCell,
53
+ formatValue,
54
+ isFormula,
55
+ isTotalRow,
56
+ parseProgressValue,
57
+ } from "./markdown/formula";
50
58
  export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
51
59
  export { createMockCover, createMockCoverSvg, parseDataUrl } from "./mock-cover";
52
60
  export * from "./network-address";