@k2b/cloud 0.27.0 → 0.29.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 +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +113 -24
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
- package/src/ai/chat/message-actions.tsx +124 -102
- package/src/ai/chat/message-utils.ts +57 -6
- package/src/ai/chat/messages.ts +214 -0
- package/src/ai/chat/presentation.tsx +67 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +69 -10
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/client/controller.ts +23 -6
- package/src/ai/code-mode-skill.ts +21 -25
- package/src/ai/code-runtime-tools.ts +5 -1
- package/src/ai/code-source-contracts.ts +2 -2
- package/src/ai/compaction.ts +22 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +23 -17
- package/src/ai/executor.ts +262 -99
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +3 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +26 -10
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +14 -6
- package/src/ai/store.ts +102 -34
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +9 -22
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +104 -0
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +29 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +82 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +66 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/cli/admin/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/outgoing-mail.ts +189 -1
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/help/store.ts +2 -1
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +12 -1
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +88 -6
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +33 -5
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/notifications/user.ts +6 -0
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +103 -10
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +115 -4
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/transport.ts +5 -1
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +48 -4
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/platform-messages.ts +1 -1
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +38 -17
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -23,7 +23,7 @@ import { taskListExtension } from "./extensions/task-list";
|
|
|
23
23
|
type MarkdownProfile = "content" | "help";
|
|
24
24
|
type LinkStyle = "widget" | "plain";
|
|
25
25
|
|
|
26
|
-
const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: MarkedExtension) => {
|
|
26
|
+
const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: MarkedExtension, locale?: string) => {
|
|
27
27
|
const marked = new Marked();
|
|
28
28
|
|
|
29
29
|
marked.use({
|
|
@@ -37,7 +37,8 @@ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: Ma
|
|
|
37
37
|
marked.use(taskListExtension());
|
|
38
38
|
marked.use(tablesExtension());
|
|
39
39
|
// Plain links keep marked's own renderer: an anchor around the link text.
|
|
40
|
-
|
|
40
|
+
// Cloud links are calm reference pills and quiet web links from `@k2b/ui`.
|
|
41
|
+
if (links === "widget") marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank", locale }));
|
|
41
42
|
marked.use(imagesExtension());
|
|
42
43
|
marked.use(katexExtension());
|
|
43
44
|
marked.use(codeExtension());
|
|
@@ -58,7 +59,7 @@ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale
|
|
|
58
59
|
const byStyle = instances.get(infoBlocks) ?? new Map<string, Marked>();
|
|
59
60
|
instances.set(infoBlocks, byStyle);
|
|
60
61
|
const key = `${profile}:${links}`;
|
|
61
|
-
const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks);
|
|
62
|
+
const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks, locale);
|
|
62
63
|
byStyle.set(key, instance);
|
|
63
64
|
return instance;
|
|
64
65
|
};
|
|
@@ -66,7 +67,7 @@ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale
|
|
|
66
67
|
const marked = markedFor("content");
|
|
67
68
|
|
|
68
69
|
export type MarkdownRenderOptions = {
|
|
69
|
-
/** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
|
|
70
|
+
/** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. The default renders references to Cloud content as calm pills and web links as quiet text links. */
|
|
70
71
|
links?: LinkStyle;
|
|
71
72
|
/** Request locale for the screen-reader names of untitled info blocks. Defaults to English on the server and the page language in the browser. */
|
|
72
73
|
locale?: string;
|
|
@@ -111,7 +112,15 @@ const sanitizeRenderedHtml = (html: string): string =>
|
|
|
111
112
|
],
|
|
112
113
|
allowedAttributes: {
|
|
113
114
|
"*": ["aria-hidden", "aria-label", "class", "data-help-icon", "data-tone", "id", "title"],
|
|
114
|
-
a: [
|
|
115
|
+
a: [
|
|
116
|
+
"href",
|
|
117
|
+
"name",
|
|
118
|
+
"rel",
|
|
119
|
+
"target",
|
|
120
|
+
"title",
|
|
121
|
+
{ name: "data-link", multiple: false, values: ["web", "mail"] },
|
|
122
|
+
{ name: "data-reference", multiple: false, values: ["pdf", "image", "design", "file", "note", "heading", "task", "page"] },
|
|
123
|
+
],
|
|
115
124
|
annotation: ["encoding"],
|
|
116
125
|
aside: [{ name: "role", multiple: false, values: ["note"] }],
|
|
117
126
|
code: ["class"],
|
|
@@ -27,10 +27,3 @@ export const IMAGE_STYLES = {
|
|
|
27
27
|
img: "block max-h-[400px] rounded border border-gray-200 dark:border-gray-700",
|
|
28
28
|
caption: "text-sm text-gray-500 dark:text-gray-400 mt-2 italic",
|
|
29
29
|
} as const;
|
|
30
|
-
|
|
31
|
-
/** Link widget styles */
|
|
32
|
-
export const LINK_STYLES = {
|
|
33
|
-
link: "md-link-widget",
|
|
34
|
-
label: "md-link-label",
|
|
35
|
-
icon: "md-link-icon",
|
|
36
|
-
} as const;
|
package/src/shared/redirect.ts
CHANGED
|
@@ -9,7 +9,8 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
|
|
|
9
9
|
|
|
10
10
|
try {
|
|
11
11
|
const url = new URL(trimmed, REDIRECT_BASE);
|
|
12
|
-
|
|
12
|
+
// Dot segments can resolve to `//host`, which a browser reads as another host.
|
|
13
|
+
if (url.origin !== REDIRECT_BASE || url.pathname.startsWith("//")) return undefined;
|
|
13
14
|
return `${url.pathname}${url.search}${url.hash}`;
|
|
14
15
|
} catch {
|
|
15
16
|
return undefined;
|
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AssertionError,
|
|
3
|
+
Context,
|
|
4
|
+
defaultOperators,
|
|
5
|
+
type Emitter,
|
|
6
|
+
type FilterImplOptions,
|
|
7
|
+
Liquid,
|
|
8
|
+
LiquidError,
|
|
9
|
+
type Operators,
|
|
10
|
+
toValue,
|
|
11
|
+
toValueSync,
|
|
12
|
+
} from "liquidjs";
|
|
2
13
|
|
|
3
14
|
const TEMPLATE_MAX_BYTES = 200_000;
|
|
4
15
|
const RENDER_MAX_BYTES = 300_000;
|
|
16
|
+
// Synchronous rendering blocks every request in the app process. Legitimate templates
|
|
17
|
+
// take milliseconds; keep this well below normal request latency budgets.
|
|
18
|
+
const RENDER_TIMEOUT_MS = 1_000;
|
|
5
19
|
|
|
6
20
|
type LiquidEngine = Liquid;
|
|
7
|
-
export type LiquidTemplateErrorReason = "render_too_large";
|
|
21
|
+
export type LiquidTemplateErrorReason = "render_too_large" | "render_timeout" | "render_memory_limit";
|
|
8
22
|
|
|
9
23
|
export class LiquidTemplateError extends Error {
|
|
10
24
|
constructor(
|
|
@@ -22,6 +36,7 @@ export type LiquidTemplateOptions = {
|
|
|
22
36
|
escapeOutput?: boolean | ((value: unknown) => string);
|
|
23
37
|
templateMaxBytes?: number;
|
|
24
38
|
renderMaxBytes?: number;
|
|
39
|
+
renderTimeoutMs?: number;
|
|
25
40
|
memoryLimit?: number;
|
|
26
41
|
};
|
|
27
42
|
|
|
@@ -52,6 +67,35 @@ const TEMPLATE_TAG_RE = /{%-?\s*([A-Za-z_][A-Za-z0-9_]*)\b/g;
|
|
|
52
67
|
|
|
53
68
|
const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
|
|
54
69
|
|
|
70
|
+
class BoundedEmitter implements Emitter {
|
|
71
|
+
buffer = "";
|
|
72
|
+
private bytes = 0;
|
|
73
|
+
private lastCodeUnit = 0;
|
|
74
|
+
|
|
75
|
+
constructor(private readonly maxBytes: number) {}
|
|
76
|
+
|
|
77
|
+
write(input: unknown): void {
|
|
78
|
+
const value: unknown = toValue(input);
|
|
79
|
+
// Match LiquidJS's emitter: nil is empty, arrays concatenate their elements.
|
|
80
|
+
if (value === null || value === undefined) return;
|
|
81
|
+
if (Array.isArray(value)) {
|
|
82
|
+
for (const item of value) this.write(item);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const chunk = String(value);
|
|
86
|
+
if (!chunk) return;
|
|
87
|
+
const last = this.lastCodeUnit;
|
|
88
|
+
const first = chunk.charCodeAt(0);
|
|
89
|
+
// A surrogate pair split across writes encodes as four bytes, rather than six.
|
|
90
|
+
const paired = last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff;
|
|
91
|
+
const bytes = this.bytes + byteLength(chunk) - (paired ? 2 : 0);
|
|
92
|
+
if (bytes > this.maxBytes) throw new LiquidTemplateError("render_too_large", "Rendered template is too large");
|
|
93
|
+
this.bytes = bytes;
|
|
94
|
+
this.lastCodeUnit = chunk.charCodeAt(chunk.length - 1);
|
|
95
|
+
this.buffer += chunk;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
export const escapeTemplateOutput = (value: unknown): string =>
|
|
56
100
|
String(value).replace(/[&<>"'`=/]/g, (char) => {
|
|
57
101
|
switch (char) {
|
|
@@ -76,16 +120,34 @@ export const escapeTemplateOutput = (value: unknown): string =>
|
|
|
76
120
|
}
|
|
77
121
|
});
|
|
78
122
|
|
|
123
|
+
const expandedTextLength = (input: unknown): number => {
|
|
124
|
+
const value: unknown = toValue(input);
|
|
125
|
+
if (typeof value === "string") return value.length;
|
|
126
|
+
if (Array.isArray(value)) return value.reduce((length: number, item: unknown) => length + expandedTextLength(item), 0);
|
|
127
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).length;
|
|
128
|
+
return 0;
|
|
129
|
+
};
|
|
130
|
+
|
|
79
131
|
const createEngine = (options: LiquidTemplateOptions = {}) => {
|
|
80
132
|
const outputEscape =
|
|
81
133
|
typeof options.escapeOutput === "function" ? options.escapeOutput : options.escapeOutput === false ? undefined : escapeTemplateOutput;
|
|
134
|
+
const operators: Operators = {};
|
|
135
|
+
for (const [name, handler] of Object.entries(defaultOperators)) {
|
|
136
|
+
operators[name] = (...args: [unknown, Context] | [unknown, unknown, Context]): boolean => {
|
|
137
|
+
const result: boolean = Reflect.apply(handler, undefined, args);
|
|
138
|
+
const context = args[args.length - 1];
|
|
139
|
+
if (context instanceof Context) context.renderLimit.check(performance.now());
|
|
140
|
+
return result;
|
|
141
|
+
};
|
|
142
|
+
}
|
|
82
143
|
const engine = new Liquid({
|
|
144
|
+
operators,
|
|
83
145
|
strictVariables: true,
|
|
84
146
|
strictFilters: true,
|
|
85
147
|
ownPropertyOnly: true,
|
|
86
148
|
...(outputEscape ? { outputEscape } : {}),
|
|
87
149
|
parseLimit: options.templateMaxBytes ?? TEMPLATE_MAX_BYTES,
|
|
88
|
-
renderLimit: options.
|
|
150
|
+
renderLimit: options.renderTimeoutMs ?? RENDER_TIMEOUT_MS,
|
|
89
151
|
memoryLimit: options.memoryLimit ?? 2_000_000,
|
|
90
152
|
cache: false,
|
|
91
153
|
dynamicPartials: false,
|
|
@@ -94,7 +156,42 @@ const createEngine = (options: LiquidTemplateOptions = {}) => {
|
|
|
94
156
|
partials: [],
|
|
95
157
|
});
|
|
96
158
|
|
|
159
|
+
const renderTemplates = engine.renderer.renderTemplates.bind(engine.renderer);
|
|
160
|
+
engine.renderer.renderTemplates = function* (templates, context, emitter) {
|
|
161
|
+
// Capture renders without an emitter; bound its intermediate buffer as well.
|
|
162
|
+
const result = yield* renderTemplates(
|
|
163
|
+
templates,
|
|
164
|
+
context,
|
|
165
|
+
emitter ?? new BoundedEmitter(Math.max(options.renderMaxBytes ?? RENDER_MAX_BYTES, RENDER_MAX_BYTES)),
|
|
166
|
+
);
|
|
167
|
+
// LiquidJS checks before templates and every loop body (even an empty one).
|
|
168
|
+
// Check after them too, so a slow final filter cannot return over budget.
|
|
169
|
+
context.renderLimit.check(performance.now());
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
for (const name of ["push", "concat", "unshift", "map"]) {
|
|
174
|
+
const filter = engine.filters[name];
|
|
175
|
+
if (!filter) continue;
|
|
176
|
+
const handler = typeof filter === "function" ? filter : filter.handler;
|
|
177
|
+
const bounded: FilterImplOptions = function* (...args: [unknown, ...unknown[]]): Generator<unknown, unknown, unknown> {
|
|
178
|
+
const result: unknown = yield handler.apply(this, args);
|
|
179
|
+
// Charge added text only; charging the left array again would compound.
|
|
180
|
+
this.context.memoryLimit.use(expandedTextLength(name === "map" ? result : args[1]));
|
|
181
|
+
return result;
|
|
182
|
+
};
|
|
183
|
+
engine.registerFilter(name, typeof filter === "function" ? bounded : { ...filter, handler: bounded });
|
|
184
|
+
}
|
|
97
185
|
for (const [name, filter] of Object.entries(options.filters ?? {})) engine.registerFilter(name, filter);
|
|
186
|
+
for (const [name, filter] of Object.entries(engine.filters)) {
|
|
187
|
+
const handler = typeof filter === "function" ? filter : filter.handler;
|
|
188
|
+
const timed: FilterImplOptions = function* (...args: [unknown, ...unknown[]]): Generator<unknown, unknown, unknown> {
|
|
189
|
+
const result: unknown = yield handler.apply(this, args);
|
|
190
|
+
this.context.renderLimit.check(performance.now());
|
|
191
|
+
return result;
|
|
192
|
+
};
|
|
193
|
+
engine.registerFilter(name, typeof filter === "function" ? timed : { ...filter, handler: timed });
|
|
194
|
+
}
|
|
98
195
|
return engine;
|
|
99
196
|
};
|
|
100
197
|
|
|
@@ -126,11 +223,27 @@ export const validateLiquidTemplate = (
|
|
|
126
223
|
export const renderLiquidTemplate = (template: string, data: Record<string, unknown>, options: LiquidTemplateOptions = {}): string => {
|
|
127
224
|
const valid = validateLiquidTemplate(template, options);
|
|
128
225
|
if (!valid.ok) throw new Error(valid.error);
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
226
|
+
const engine = engineFor(options);
|
|
227
|
+
const context = new Context(data, engine.options, { sync: true }, { liquid: engine });
|
|
228
|
+
const emitter = new BoundedEmitter(options.renderMaxBytes ?? RENDER_MAX_BYTES);
|
|
229
|
+
try {
|
|
230
|
+
toValueSync(engine.renderer.renderTemplates(engine.parse(template), context, emitter));
|
|
231
|
+
return emitter.buffer;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
// LiquidJS wraps emitter and limiter failures in RenderError.originalError.
|
|
234
|
+
let original = error;
|
|
235
|
+
while (original instanceof LiquidError && original.originalError) original = original.originalError;
|
|
236
|
+
if (original instanceof LiquidTemplateError) throw original;
|
|
237
|
+
if (original instanceof AssertionError) {
|
|
238
|
+
if (original.message === "template render limit exceeded") {
|
|
239
|
+
throw new LiquidTemplateError("render_timeout", "Template rendering exceeded its time budget");
|
|
240
|
+
}
|
|
241
|
+
if (original.message === "memory alloc limit exceeded") {
|
|
242
|
+
throw new LiquidTemplateError("render_memory_limit", "Template rendering exceeded its memory limit");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
throw error;
|
|
132
246
|
}
|
|
133
|
-
return rendered;
|
|
134
247
|
};
|
|
135
248
|
|
|
136
249
|
export const liquidTemplateVariables = (template: string, options: LiquidTemplateOptions = {}): string[] => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useLocale } from "@k2b/ui";
|
|
2
|
+
import { Show } from "solid-js";
|
|
3
|
+
import { appBadgeCount } from "./app-badges";
|
|
4
|
+
import { railMessages } from "./rail-messages";
|
|
5
|
+
|
|
6
|
+
/** What a screen reader hears after the app name; empty without a count. Says no more than the "99+" shown. */
|
|
7
|
+
export const useAppBadgeDescription = (endpoint: () => string | undefined) => {
|
|
8
|
+
const locale = useLocale();
|
|
9
|
+
return () => {
|
|
10
|
+
const count = appBadgeCount(endpoint());
|
|
11
|
+
const t = railMessages.resolve([locale()]).t;
|
|
12
|
+
return count > 99 ? t.badgeMany : count > 0 ? t.badge({ count }) : "";
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Count on the corner of an app icon. It is positioned over the icon and never
|
|
18
|
+
* takes layout space, so appearing, growing to "99+", or clearing moves
|
|
19
|
+
* nothing. Decorative: the link carries the description.
|
|
20
|
+
*/
|
|
21
|
+
export function AppBadge(props: { endpoint?: string }) {
|
|
22
|
+
const count = () => appBadgeCount(props.endpoint);
|
|
23
|
+
return (
|
|
24
|
+
<Show when={count() > 0}>
|
|
25
|
+
<span class="cloud-app-badge" aria-hidden="true">
|
|
26
|
+
{count() > 99 ? "99+" : count()}
|
|
27
|
+
</span>
|
|
28
|
+
</Show>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -3,6 +3,7 @@ import { IconButton, ScrollArea, TextInput, useLocale } from "@k2b/ui";
|
|
|
3
3
|
import { createSignal, For, type JSX, Show } from "solid-js";
|
|
4
4
|
import { Dynamic } from "solid-js/web";
|
|
5
5
|
import type { CloudTheme } from "../shared/theme";
|
|
6
|
+
import { AppBadge, useAppBadgeDescription } from "./AppBadge";
|
|
6
7
|
import { MobileProfileActions } from "./MobileProfileActions";
|
|
7
8
|
import { platformMessages } from "./platform-messages";
|
|
8
9
|
import { openRailEditor } from "./RailEditor";
|
|
@@ -17,6 +18,8 @@ export type AppLaunchpadApp = {
|
|
|
17
18
|
href: string;
|
|
18
19
|
description?: string;
|
|
19
20
|
accent?: string;
|
|
21
|
+
/** The app's `nav.badge` endpoint. */
|
|
22
|
+
badge?: string;
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
export type AppLaunchpadLegalLink = {
|
|
@@ -119,39 +122,57 @@ export const AppLaunchpadPanel = (
|
|
|
119
122
|
<section class="mb-5" aria-label={t().shortcuts}>
|
|
120
123
|
<div class="flex flex-wrap justify-center gap-3">
|
|
121
124
|
<For each={shortcuts()}>
|
|
122
|
-
{(shortcut) =>
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
125
|
+
{(shortcut) => {
|
|
126
|
+
const badge = useAppBadgeDescription(() => shortcut.badge);
|
|
127
|
+
return (
|
|
128
|
+
<a
|
|
129
|
+
onClick={follow}
|
|
130
|
+
href={shortcut.href}
|
|
131
|
+
class="group flex w-16 min-w-0 flex-col items-center gap-1 rounded-lg p-1 text-center focus-ui"
|
|
132
|
+
>
|
|
133
|
+
<span class="relative grid h-9 w-9 place-items-center rounded-xl bg-[var(--ui-hover)] text-lg transition-colors group-hover:bg-[var(--ui-active)]">
|
|
134
|
+
<i class={shortcut.iconClass} aria-hidden="true" />
|
|
135
|
+
<AppBadge endpoint={shortcut.badge} />
|
|
136
|
+
</span>
|
|
137
|
+
<span class="max-w-full truncate text-[11px] font-medium text-primary dark:text-white">
|
|
138
|
+
{shortcut.label}
|
|
139
|
+
<Show when={badge()}>
|
|
140
|
+
<span class="sr-only">, {badge()}</span>
|
|
141
|
+
</Show>
|
|
142
|
+
</span>
|
|
143
|
+
</a>
|
|
144
|
+
);
|
|
145
|
+
}}
|
|
134
146
|
</For>
|
|
135
147
|
</div>
|
|
136
148
|
</section>
|
|
137
149
|
</Show>
|
|
138
150
|
<div class="launchpad-apps-grid flex flex-wrap justify-center gap-x-4 gap-y-4 sm:gap-x-7 sm:gap-y-6">
|
|
139
151
|
<For each={apps()}>
|
|
140
|
-
{(app) =>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
class="app-icon grid h-12 w-12 place-items-center rounded-[0.95rem] text-[1.25rem] sm:h-16 sm:w-16 sm:rounded-[1.25rem] sm:text-[1.7rem]"
|
|
148
|
-
style={appIconStyle(app)}
|
|
152
|
+
{(app) => {
|
|
153
|
+
const badge = useAppBadgeDescription(() => app.badge);
|
|
154
|
+
return (
|
|
155
|
+
<a
|
|
156
|
+
onClick={follow}
|
|
157
|
+
href={app.href}
|
|
158
|
+
class="launchpad-app group flex w-[4.75rem] min-w-0 flex-col items-center gap-1.5 rounded-2xl p-1 text-center focus-ui sm:w-[6.25rem] sm:gap-2 sm:p-2"
|
|
149
159
|
>
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
<span
|
|
161
|
+
class="app-icon relative grid h-12 w-12 place-items-center rounded-[0.95rem] text-[1.25rem] sm:h-16 sm:w-16 sm:rounded-[1.25rem] sm:text-[1.7rem]"
|
|
162
|
+
style={appIconStyle(app)}
|
|
163
|
+
>
|
|
164
|
+
<i class={app.iconClass} />
|
|
165
|
+
<AppBadge endpoint={app.badge} />
|
|
166
|
+
</span>
|
|
167
|
+
<span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">
|
|
168
|
+
{app.label}
|
|
169
|
+
<Show when={badge()}>
|
|
170
|
+
<span class="sr-only">, {badge()}</span>
|
|
171
|
+
</Show>
|
|
172
|
+
</span>
|
|
173
|
+
</a>
|
|
174
|
+
);
|
|
175
|
+
}}
|
|
155
176
|
</For>
|
|
156
177
|
</div>
|
|
157
178
|
<Show when={!apps().length && !shortcuts().length}>
|
|
@@ -102,7 +102,7 @@ export default function GlobalAnnouncements(props: Props) {
|
|
|
102
102
|
<MarkdownView
|
|
103
103
|
trustedHtml={banner.bodyHtml}
|
|
104
104
|
headingScale="compact"
|
|
105
|
-
class="max-h-36 overflow-y-auto overscroll-contain pr-
|
|
105
|
+
class="-mx-2 max-h-36 overflow-y-auto overscroll-contain pl-2 pr-3 [&_p]:my-0"
|
|
106
106
|
/>
|
|
107
107
|
</NoticeCard>
|
|
108
108
|
<IconButton size="sm" label={t().dismissBanner} onClick={() => dismissBanner(banner.version)}>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { IslandErrorProps } from "@k2b/ssr";
|
|
2
|
+
import { i18n } from "@k2b/stdlib";
|
|
3
|
+
import { Button, Placeholder, useLocale } from "@k2b/ui";
|
|
4
|
+
|
|
5
|
+
const messages = i18n.define({
|
|
6
|
+
baseLocale: "en",
|
|
7
|
+
messages: {
|
|
8
|
+
en: { title: "This section could not be displayed.", retry: "Try again" },
|
|
9
|
+
de: { title: "Dieser Bereich konnte nicht angezeigt werden.", retry: "Erneut versuchen" },
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Takes the place of an island or client component whose rendering failed.
|
|
15
|
+
* `defineApp` hands it to @k2b/ssr as `errorFallback`, so every island of every
|
|
16
|
+
* app gets it; the locale comes from `<html lang>`. "Try again" remounts the
|
|
17
|
+
* island with its original props.
|
|
18
|
+
*/
|
|
19
|
+
export default function IslandError(props: IslandErrorProps) {
|
|
20
|
+
const locale = useLocale();
|
|
21
|
+
const t = () => messages.resolve([locale()]).t;
|
|
22
|
+
return (
|
|
23
|
+
<Placeholder
|
|
24
|
+
state="error"
|
|
25
|
+
title={t().title}
|
|
26
|
+
action={
|
|
27
|
+
<Button variant="secondary" onClick={() => props.reset()}>
|
|
28
|
+
{t().retry}
|
|
29
|
+
</Button>
|
|
30
|
+
}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
package/src/ssr/Layout.tsx
CHANGED
|
@@ -61,6 +61,7 @@ function buildNavLinks(
|
|
|
61
61
|
match: resolveNavMatch(app) ?? app.nav.href.split("?")[0] ?? app.nav.href,
|
|
62
62
|
description: app.description,
|
|
63
63
|
accent: app.appearance?.accent,
|
|
64
|
+
badge: app.nav.badge,
|
|
64
65
|
} satisfies LayoutAppLink,
|
|
65
66
|
}));
|
|
66
67
|
const primary = links.filter((entry) => entry.section === "primary").map((entry) => entry.link);
|
|
@@ -75,6 +76,7 @@ function buildNavLinks(
|
|
|
75
76
|
match: "/admin",
|
|
76
77
|
description: t.platformAdministration,
|
|
77
78
|
accent: undefined,
|
|
79
|
+
badge: undefined,
|
|
78
80
|
});
|
|
79
81
|
}
|
|
80
82
|
return { primary, more };
|
|
@@ -166,6 +168,7 @@ export default function Layout(props: LayoutProps) {
|
|
|
166
168
|
href: app.href,
|
|
167
169
|
description: app.description,
|
|
168
170
|
accent: app.accent,
|
|
171
|
+
badge: app.badge,
|
|
169
172
|
}));
|
|
170
173
|
const searchLinks = navigationSearchItems(
|
|
171
174
|
runtime.apps,
|
package/src/ssr/LayoutHelp.tsx
CHANGED
|
@@ -95,11 +95,13 @@ const registerTopic = (topic: HelpTopic) => {
|
|
|
95
95
|
/** Register an app's server-prepared Markdown manifest with the shared help UI. */
|
|
96
96
|
export function LayoutHelpDocuments(props: LayoutHelpDocumentsProps) {
|
|
97
97
|
onMount(() => {
|
|
98
|
-
|
|
98
|
+
// Cleanup runs while this unmounts, when a parent `<Show>` may already have dropped the props' source.
|
|
99
|
+
const pageBase = props.pageBase;
|
|
100
|
+
window.__cloudLayoutHelpPageBase = pageBase;
|
|
99
101
|
const disposers = props.documents.map((document) => registerTopic({ ...document, kind: "document" }));
|
|
100
102
|
onCleanup(() => {
|
|
101
103
|
disposers.forEach((dispose) => dispose());
|
|
102
|
-
if (window.__cloudLayoutHelpPageBase ===
|
|
104
|
+
if (window.__cloudLayoutHelpPageBase === pageBase) delete window.__cloudLayoutHelpPageBase;
|
|
103
105
|
});
|
|
104
106
|
});
|
|
105
107
|
return null;
|
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
import { ScrollArea, Tooltip, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
|
3
3
|
import type { RailSnapshot } from "../contracts/rail-preferences";
|
|
4
|
+
import { AppBadge, useAppBadgeDescription } from "./AppBadge";
|
|
4
5
|
import { appAccentStyle } from "./app-appearance";
|
|
6
|
+
import { watchAppBadges } from "./app-badges";
|
|
5
7
|
import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
|
|
6
8
|
import { projectRailNavigation, type RailApp, type RailLink, railLinkActive } from "./rail-navigation";
|
|
7
9
|
|
|
8
10
|
function RailIcon(props: { link: RailLink; currentPath: string }) {
|
|
9
11
|
let target: HTMLAnchorElement | undefined;
|
|
10
12
|
const active = () => railLinkActive(props.link, props.currentPath);
|
|
13
|
+
const badge = useAppBadgeDescription(() => props.link.badge);
|
|
11
14
|
return (
|
|
12
15
|
<>
|
|
13
16
|
<a
|
|
14
17
|
ref={target}
|
|
15
18
|
href={props.link.href}
|
|
16
|
-
class={`rail-item shrink-0 ${active() ? "rail-item-active" : ""}`}
|
|
17
|
-
aria-label={props.link.label}
|
|
19
|
+
class={`rail-item relative shrink-0 ${active() ? "rail-item-active" : ""}`}
|
|
20
|
+
aria-label={badge() ? `${props.link.label}, ${badge()}` : props.link.label}
|
|
18
21
|
aria-current={active() ? "page" : undefined}
|
|
19
22
|
style={appAccentStyle(props.link.accent)}
|
|
20
23
|
>
|
|
21
24
|
<i class={`${props.link.iconClass} text-base`} aria-hidden="true" />
|
|
22
25
|
<span class="sr-only">{props.link.label}</span>
|
|
26
|
+
<AppBadge endpoint={props.link.badge} />
|
|
23
27
|
</a>
|
|
24
28
|
<Tooltip target={() => target} content={props.link.label} placement="right" delay={0} />
|
|
25
29
|
</>
|
|
@@ -36,10 +40,13 @@ export default function RailApps(props: { apps: RailApp[]; settings: RailSnapsho
|
|
|
36
40
|
const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
|
|
37
41
|
sync();
|
|
38
42
|
syncLocation();
|
|
43
|
+
// Every visible app, not only the rail's: the app grid shows the same counts.
|
|
44
|
+
const stopBadges = watchAppBadges(props.apps.flatMap((app) => (app.badge ? [app.badge] : [])));
|
|
39
45
|
window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
40
46
|
window.addEventListener("popstate", syncLocation);
|
|
41
47
|
window.addEventListener("hashchange", syncLocation);
|
|
42
48
|
onCleanup(() => {
|
|
49
|
+
stopBadges();
|
|
43
50
|
window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
|
|
44
51
|
window.removeEventListener("popstate", syncLocation);
|
|
45
52
|
window.removeEventListener("hashchange", syncLocation);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
import { APP_BADGES_REFRESH_EVENT } from "../browser/app-badges";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shortest time between two scheduled reads in one tab, and the longest one
|
|
6
|
+
* read may take. Badges are a hint to open an app, not a live view, so one
|
|
7
|
+
* read per minute keeps them current while someone works elsewhere without a
|
|
8
|
+
* steady stream of requests per tab.
|
|
9
|
+
*/
|
|
10
|
+
export const APP_BADGE_INTERVAL_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
// Keyed by endpoint, so a pinned app shortcut and the app's own entry share one
|
|
13
|
+
// count. Only the browser ever writes it; server renders always see no badge.
|
|
14
|
+
const [counts, setCounts] = createSignal<ReadonlyMap<string, number>>(new Map());
|
|
15
|
+
|
|
16
|
+
/** The count for a declared `nav.badge` endpoint; 0 until the browser has read it. */
|
|
17
|
+
export const appBadgeCount = (endpoint: string | undefined): number => (endpoint ? (counts().get(endpoint) ?? 0) : 0);
|
|
18
|
+
|
|
19
|
+
/** A positive safe integer from an `AppNavBadge` body, else 0. */
|
|
20
|
+
export const parseAppBadge = (body: unknown): number => {
|
|
21
|
+
if (typeof body !== "object" || body === null || !("count" in body)) return 0;
|
|
22
|
+
const count = body.count;
|
|
23
|
+
return typeof count === "number" && Number.isSafeInteger(count) && count > 0 ? count : 0;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const readBadge = async (endpoint: string, signal: AbortSignal): Promise<number> => {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(endpoint, { credentials: "same-origin", headers: { accept: "application/json" }, signal });
|
|
29
|
+
return response.status === 200 ? parseAppBadge(await response.json()) : 0;
|
|
30
|
+
} catch {
|
|
31
|
+
// An unreachable or failing app shows no badge rather than a stale one.
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let watching = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the badge endpoints after hydration, every interval while the tab is
|
|
40
|
+
* visible, when a hidden tab becomes visible again after the interval, and on
|
|
41
|
+
* `refreshAppBadges()`. A hidden tab sends nothing and keeps its schedule.
|
|
42
|
+
* Each endpoint has at most one read in flight, and its count shows as soon as
|
|
43
|
+
* that read settles, so a slow app delays only its own badge. A refresh while
|
|
44
|
+
* a read runs queues one more read after it instead of another request, and a
|
|
45
|
+
* read still running after the interval gives up. Returns a disposer.
|
|
46
|
+
*/
|
|
47
|
+
export const watchAppBadges = (endpoints: readonly string[]): (() => void) => {
|
|
48
|
+
const unique = [...new Set(endpoints)];
|
|
49
|
+
if (typeof window === "undefined" || unique.length === 0 || watching) return () => {};
|
|
50
|
+
watching = true;
|
|
51
|
+
const stopped = new AbortController();
|
|
52
|
+
// Endpoints with a read in flight, mapped to whether one more read waits for it.
|
|
53
|
+
const running = new Map<string, boolean>();
|
|
54
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
56
|
+
|
|
57
|
+
const read = (endpoint: string) => {
|
|
58
|
+
if (running.has(endpoint)) {
|
|
59
|
+
running.set(endpoint, true);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
running.set(endpoint, false);
|
|
63
|
+
void readBadge(endpoint, AbortSignal.any([stopped.signal, AbortSignal.timeout(APP_BADGE_INTERVAL_MS)])).then((count) => {
|
|
64
|
+
if (stopped.signal.aborted) return;
|
|
65
|
+
setCounts((previous) => new Map(previous).set(endpoint, count));
|
|
66
|
+
const queued = running.get(endpoint);
|
|
67
|
+
running.delete(endpoint);
|
|
68
|
+
if (queued && document.visibilityState !== "hidden") read(endpoint);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const refresh = () => {
|
|
72
|
+
// Leave the pending read in place, so the tab still reads on time once it is visible again.
|
|
73
|
+
if (document.visibilityState === "hidden") return;
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
last = Date.now();
|
|
76
|
+
timer = setTimeout(refresh, APP_BADGE_INTERVAL_MS);
|
|
77
|
+
for (const endpoint of unique) read(endpoint);
|
|
78
|
+
};
|
|
79
|
+
const resume = () => {
|
|
80
|
+
if (document.visibilityState !== "hidden" && Date.now() - last >= APP_BADGE_INTERVAL_MS) refresh();
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
document.addEventListener("visibilitychange", resume);
|
|
84
|
+
window.addEventListener(APP_BADGES_REFRESH_EVENT, refresh);
|
|
85
|
+
refresh();
|
|
86
|
+
return () => {
|
|
87
|
+
watching = false;
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
stopped.abort();
|
|
90
|
+
document.removeEventListener("visibilitychange", resume);
|
|
91
|
+
window.removeEventListener(APP_BADGES_REFRESH_EVENT, refresh);
|
|
92
|
+
};
|
|
93
|
+
};
|
|
@@ -98,7 +98,7 @@ export const platformMessages = i18n.define({
|
|
|
98
98
|
skills: "Skills",
|
|
99
99
|
projects: "Projekte",
|
|
100
100
|
aiUsage: "Nutzung",
|
|
101
|
-
aiQuotas: "
|
|
101
|
+
aiQuotas: "Assistenten-Limits",
|
|
102
102
|
backgroundJobs: "Hintergrundaufgaben",
|
|
103
103
|
apps: "Apps",
|
|
104
104
|
appMenu: "Menü",
|
package/src/ssr/rail-messages.ts
CHANGED
|
@@ -32,6 +32,8 @@ export const railMessages = i18n.define({
|
|
|
32
32
|
saveFailed: "Could not save app bar settings. Your edits are still here.",
|
|
33
33
|
conflict: "The app bar changed in another editor. Cancel and reopen this editor to load the latest settings.",
|
|
34
34
|
invalid: "Check shortcut titles, links and icons. Each app can be pinned once; settings must fit within 16 KiB.",
|
|
35
|
+
badge: ({ count }: { count: number }) => `${count} new`,
|
|
36
|
+
badgeMany: "more than 99 new",
|
|
35
37
|
},
|
|
36
38
|
de: {
|
|
37
39
|
managed: "Von der Administration bereitgestellt",
|
|
@@ -63,6 +65,8 @@ export const railMessages = i18n.define({
|
|
|
63
65
|
conflict:
|
|
64
66
|
"Die App-Leiste wurde in einem anderen Editor geändert. Brich ab und öffne den Editor erneut, um den aktuellen Stand zu laden.",
|
|
65
67
|
invalid: "Prüfe Titel, Links und Icons. Jede App kann einmal angeheftet werden; die Einstellungen dürfen höchstens 16 KiB belegen.",
|
|
68
|
+
badge: ({ count }) => `${count} neu`,
|
|
69
|
+
badgeMany: "mehr als 99 neu",
|
|
66
70
|
},
|
|
67
71
|
},
|
|
68
72
|
});
|