@k2b/cloud 0.18.0 → 0.20.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.
@@ -84,7 +84,15 @@ export const createCloudAiMarkdownToPdfTool = (dependencies: MarkdownPdfToolDepe
84
84
  let rendered: { pdf: Uint8Array; contentType: string };
85
85
  try {
86
86
  rendered = await withAiPdfConversionSlot(() =>
87
- render({ markdown, templateId: input.template, customCss: input.customCss }, { signal: ctx.signal }),
87
+ render(
88
+ {
89
+ markdown,
90
+ title: sourcePath.slice(sourcePath.lastIndexOf("/") + 1, -".md".length),
91
+ templateId: input.template,
92
+ customCss: input.customCss,
93
+ },
94
+ { signal: ctx.signal },
95
+ ),
88
96
  );
89
97
  } catch (error) {
90
98
  throw ctx.signal.aborted ? cancelled() : error;
package/src/config/env.ts CHANGED
@@ -72,6 +72,11 @@ const registry = defineEnv({
72
72
  example: "localhost:3000",
73
73
  doc: "Public Cloud URL used to bootstrap the `app.url` setting; the stored setting wins once written.",
74
74
  },
75
+ GOTENBERG_URL: {
76
+ schema: z.string(),
77
+ example: "http://localhost:3001",
78
+ doc: "Internal Gotenberg base URL used to bootstrap the `gotenberg.url` PDF rendering setting; the stored setting wins once written.",
79
+ },
75
80
  APP_SECRET: {
76
81
  schema: z.string(),
77
82
  default: "",
@@ -9,20 +9,33 @@ type LocaleContext = {
9
9
  req: { raw: { headers: Headers } };
10
10
  };
11
11
 
12
+ /**
13
+ * Languages Cloud ships platform catalogs for and offers in its language
14
+ * picker. `Accept-Language` negotiation prefers a tag in one of these.
15
+ */
16
+ const CATALOG_LANGUAGES = new Set(["en", "de"]);
17
+
12
18
  /**
13
19
  * The caller's explicit locale preference, or `undefined` when the request
14
20
  * carries none. Precedence: `x-cloud-locale` transport metadata, then the
15
- * `cloud.locale` cookie, then `Accept-Language` in quality order. Every
16
- * candidate is canonicalized; invalid tags fall through to the next source.
21
+ * `cloud.locale` cookie, then `Accept-Language`. Every candidate is
22
+ * canonicalized; invalid tags fall through to the next source.
23
+ *
24
+ * `Accept-Language` picks the first tag in quality order whose language has a
25
+ * catalog, keeping its region (`de-CH`, `en-GB`) for formatting. Without such a
26
+ * tag, the first valid tag still wins so formatting follows the browser.
17
27
  */
18
28
  export const preferredLocale = (headers: Headers): string | undefined => {
19
29
  const explicit = canonicalLocale(headers.get(LOCALE_HEADER)) ?? canonicalLocale(readCookie(headers, LOCALE_COOKIE));
20
30
  if (explicit) return explicit;
31
+ let firstValid: string | undefined;
21
32
  for (const tag of i18n.parseAcceptLanguage(headers.get("Accept-Language"))) {
22
33
  const candidate = canonicalLocale(tag);
23
- if (candidate) return candidate;
34
+ if (!candidate) continue;
35
+ if (CATALOG_LANGUAGES.has(new Intl.Locale(candidate).language)) return candidate;
36
+ firstValid ??= candidate;
24
37
  }
25
- return undefined;
38
+ return firstValid;
26
39
  };
27
40
 
28
41
  /**
@@ -1,5 +1,5 @@
1
1
  import type { Context, ValidationTargets } from "hono";
2
- import { validator as honoValidator } from "hono-openapi";
2
+ import { type GenerateSpecOptions, validator as honoValidator } from "hono-openapi";
3
3
  import type { ZodType } from "zod";
4
4
 
5
5
  export type ValidatorError = Readonly<{
@@ -9,6 +9,28 @@ export type ValidatorError = Readonly<{
9
9
 
10
10
  export type ValidatorErrorResolver = (context: Context) => ValidatorError;
11
11
 
12
+ /**
13
+ * OpenAPI description of the 400 body `validator()` sends: `{ message }`, or
14
+ * `{ code, message }` from a route's error resolver. The generated spec of
15
+ * every application uses it for validated routes that do not describe their
16
+ * own 400, in place of hono-openapi's default body, which Cloud never sends.
17
+ */
18
+ export const validationErrorResponse: Exclude<GenerateSpecOptions["defaultValidationErrorResponse"], boolean> = {
19
+ description: "Validation failed",
20
+ content: {
21
+ "application/json": {
22
+ schema: {
23
+ type: "object",
24
+ properties: {
25
+ message: { type: "string" },
26
+ code: { type: "string" },
27
+ },
28
+ required: ["message"],
29
+ },
30
+ },
31
+ },
32
+ };
33
+
12
34
  /**
13
35
  * Zod validator middleware with pretty error messages and OpenAPI support.
14
36
  * Uses hono-openapi validator for automatic OpenAPI schema generation.
@@ -132,6 +132,11 @@ export const latestGatewayRouteSnapshot = async (): Promise<GatewayRouteSnapshot
132
132
  return all.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
133
133
  };
134
134
 
135
+ /**
136
+ * Dead letters keep the event stream's limit. Gateway Ops leaves dead letters
137
+ * when rollup writes fail; if they exceeded a lowered limit, this topic, and
138
+ * with it Gateway Ops' start, would fail until they expired.
139
+ */
135
140
  export const gatewayTelemetryTopic = lazySync((sync) =>
136
141
  sync.topic<GatewayTelemetryEvent>({
137
142
  id: TOPIC_ID,
@@ -148,6 +148,7 @@ export {
148
148
  MARKDOWN_PDF_TEMPLATE_IDS,
149
149
  MarkdownPdfError,
150
150
  buildMarkdownPdfHtml,
151
+ buildPresetPdfHtml,
151
152
  getGotenbergConfig,
152
153
  attachPdfFiles,
153
154
  attachPdfFilesWithConfig,
@@ -163,6 +164,7 @@ export {
163
164
  testGotenberg,
164
165
  } from "./pdf";
165
166
  export type {
167
+ BuildPresetPdfHtmlInput,
166
168
  GotenbergConfig,
167
169
  AttachPdfFilesInput,
168
170
  RenderFacturXHtmlToPdfInput,
@@ -32,6 +32,12 @@ export type GotenbergConfig = {
32
32
 
33
33
  export type RenderHtmlToPdfInput = {
34
34
  html: string;
35
+ /**
36
+ * Document title that PDF viewers show, such as the document's name. The
37
+ * HTML's own non-blank `<title>` wins; without either, viewers show a
38
+ * random file name.
39
+ */
40
+ title?: string;
35
41
  headerHtml?: string | null;
36
42
  footerHtml?: string | null;
37
43
  filename?: string;
@@ -49,7 +55,8 @@ export type AttachPdfFilesInput = {
49
55
  attachments: Array<{ name: string; data: Blob; relationship?: "Source" | "Data" | "Alternative" | "Supplement" | "Unspecified" }>;
50
56
  };
51
57
 
52
- export type RenderFacturXHtmlToPdfInput = RenderHtmlToPdfInput & {
58
+ /** PDF/A conversion drops the document title, so viewers show the file name of a Factur-X invoice. */
59
+ export type RenderFacturXHtmlToPdfInput = Omit<RenderHtmlToPdfInput, "title"> & {
53
60
  xml: string;
54
61
  conformanceLevel?: "MINIMUM" | "BASIC WL" | "BASIC" | "EN 16931" | "EXTENDED";
55
62
  documentType?: "INVOICE" | "ORDER" | "ORDER RESPONSE";
@@ -132,7 +139,7 @@ function htmlForm(input: RenderHtmlToPdfInput, config: GotenbergConfig): FormDat
132
139
  (input.assets ?? []).reduce((sum, asset) => sum + asset.data.size, 0);
133
140
  if (total > config.maxHtmlBytes) throw new GotenbergRenderError("html_too_large", "HTML and assets exceed the configured input budget.");
134
141
  const form = new FormData();
135
- form.append("files", new Blob([offlineHtml(input.html)], { type: "text/html" }), "index.html");
142
+ form.append("files", new Blob([offlineHtml(input.html, input.title)], { type: "text/html" }), "index.html");
136
143
  if (input.headerHtml?.trim()) form.append("files", new Blob([offlineHtml(input.headerHtml)], { type: "text/html" }), "header.html");
137
144
  if (input.footerHtml?.trim()) form.append("files", new Blob([offlineHtml(input.footerHtml)], { type: "text/html" }), "footer.html");
138
145
  const names = new Set(["index.html", "header.html", "footer.html", "factur-x.xml"]);
@@ -22,6 +22,7 @@ export {
22
22
  testGotenberg,
23
23
  } from "./gotenberg";
24
24
  export type {
25
+ BuildPresetPdfHtmlInput,
25
26
  MarkdownPdfErrorCode,
26
27
  MarkdownPdfTemplateId,
27
28
  RenderMarkdownToPdfInput,
@@ -29,6 +30,7 @@ export type {
29
30
  } from "./markdown";
30
31
  export {
31
32
  buildMarkdownPdfHtml,
33
+ buildPresetPdfHtml,
32
34
  MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES,
33
35
  MARKDOWN_PDF_MAX_MARKDOWN_BYTES,
34
36
  MARKDOWN_PDF_TEMPLATE_IDS,
@@ -30,12 +30,35 @@ export class MarkdownPdfError extends Error {
30
30
 
31
31
  export type RenderMarkdownToPdfInput = {
32
32
  markdown: string;
33
+ /** Document title that PDF viewers show, such as the note or file name. */
34
+ title?: string;
33
35
  templateId?: MarkdownPdfTemplateId;
34
36
  customCss?: string;
35
37
  };
36
38
 
37
39
  export type RenderMarkdownToPdfOptions = RenderHtmlToPdfOptions;
38
40
 
41
+ export type BuildPresetPdfHtmlInput = {
42
+ /** Body HTML the application rendered from its own document format. */
43
+ html: string;
44
+ templateId?: MarkdownPdfTemplateId;
45
+ customCss?: string;
46
+ /** Application CSS for its own elements, applied after the preset and before `customCss`. */
47
+ css?: string;
48
+ };
49
+
50
+ // Noto Serif, which Gotenberg ships, forms f-ligatures for the report body. It
51
+ // has no "≠" but has "=" and the combining overlay U+0338, so Chromium would
52
+ // build "≠" from both and draw "=/". Without U+0338 in the range, such
53
+ // characters fall back whole to the next font. local() takes the PostScript
54
+ // name and fetches nothing.
55
+ const REPORT_SERIF_RANGE = "U+0-337, U+339-10FFFF";
56
+ const REPORT_SERIF_FACES = `
57
+ @font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Regular"); unicode-range: ${REPORT_SERIF_RANGE}; }
58
+ @font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Bold"); font-weight: bold; unicode-range: ${REPORT_SERIF_RANGE}; }
59
+ @font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Italic"); font-style: italic; unicode-range: ${REPORT_SERIF_RANGE}; }
60
+ @font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-BoldItalic"); font-weight: bold; font-style: italic; unicode-range: ${REPORT_SERIF_RANGE}; }`;
61
+
39
62
  const TEMPLATE_CSS: Record<MarkdownPdfTemplateId, string> = {
40
63
  document: `
41
64
  @page { size: A4; margin: 22mm 20mm 24mm; }
@@ -56,9 +79,9 @@ th { background: #f1f5f9; font-weight: 650; }
56
79
  thead { display: table-header-group; } tr { break-inside: avoid; }
57
80
  hr { border: 0; border-top: 1px solid #cbd5e1; margin: 1.5em 0; }
58
81
  `,
59
- report: `
82
+ report: `${REPORT_SERIF_FACES}
60
83
  @page { size: A4; margin: 24mm 22mm 26mm; }
61
- :root { color: #263244; font: 10.75pt/1.58 Georgia, "Times New Roman", serif; }
84
+ :root { color: #263244; font: 10.75pt/1.58 Georgia, "Report Noto Serif", "Times New Roman", serif; }
62
85
  body { margin: 0; background: #fff; }
63
86
  .markdown-document { max-width: 100%; overflow-wrap: anywhere; }
64
87
  h1, h2, h3, h4, h5, h6 { color: #13233a; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.18; break-after: avoid-page; }
@@ -112,6 +135,12 @@ const safeLink = (value: string): string | null => {
112
135
  const renderMarkdown = (source: string): string => {
113
136
  const renderer = new Renderer();
114
137
  renderer.html = ({ text }: Tokens.HTML | Tokens.Tag) => escapeHtml(text);
138
+ // marked flags text that follows an inline <pre>, <code>, <kbd> or <script>
139
+ // tag as already escaped, because it expects that tag to reach the output as
140
+ // HTML. The tag is escaped here, so the text after it is escaped as well.
141
+ renderer.text = function (token: Tokens.Text | Tokens.Escape) {
142
+ return Renderer.prototype.text.call(this, token.type === "text" && token.escaped ? { ...token, escaped: false } : token);
143
+ };
115
144
  renderer.image = ({ href, title, text }: Tokens.Image) => {
116
145
  const label = escapeHtml(text.trim() ? `Image: ${text}` : "Image");
117
146
  const safeHref = safeLink(href);
@@ -142,6 +171,10 @@ const renderMarkdown = (source: string): string => {
142
171
  }
143
172
  };
144
173
 
174
+ // Keep CSS inside its style element even when it contains an HTML end tag.
175
+ // The backslash is valid CSS escaping but no longer an HTML token.
176
+ const styleText = (css: string): string => css.replace(/<\/style/giu, "<\\/style");
177
+
145
178
  const validateCustomCss = (customCss: string): string => {
146
179
  if (byteLength(customCss) > MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES) {
147
180
  throw new MarkdownPdfError("invalid_css", "Custom CSS exceeds the 32 KiB limit.", "css_too_large");
@@ -166,15 +199,15 @@ const validateCustomCss = (customCss: string): string => {
166
199
  }
167
200
  });
168
201
 
169
- // Keep the CSS inside its style element even when input contains an HTML
170
- // end tag. The backslash is valid CSS escaping but no longer an HTML token.
171
- return root.toString().replace(/<\/style/giu, "<\\/style");
202
+ return styleText(root.toString());
172
203
  };
173
204
 
174
- export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string => {
175
- if (typeof input.markdown !== "string" || !input.markdown.trim()) {
176
- throw new MarkdownPdfError("bad_input", "Markdown must not be empty.", "markdown_empty");
177
- }
205
+ /**
206
+ * Wrap application-rendered HTML in the same print presets and validated
207
+ * custom CSS as Markdown PDFs, for documents whose Markdown dialect the
208
+ * application renders itself.
209
+ */
210
+ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
178
211
  const templateId = input.templateId;
179
212
  if (templateId !== undefined && !MARKDOWN_PDF_TEMPLATE_IDS.includes(templateId)) {
180
213
  throw new MarkdownPdfError("bad_input", "Unknown Markdown PDF template.", "unknown_template");
@@ -183,8 +216,8 @@ export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string =>
183
216
  const suppliedCustomCss = input.customCss?.trim() ?? "";
184
217
  const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
185
218
  const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
186
- const stylesheet = `${presetCss}${presetCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
187
- const content = renderMarkdown(input.markdown);
219
+ const baseCss = [presetCss, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
220
+ const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
188
221
  return `<!doctype html>
189
222
  <html lang="en">
190
223
  <head>
@@ -193,15 +226,22 @@ export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string =>
193
226
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'">
194
227
  <style>${stylesheet}</style>
195
228
  </head>
196
- <body><main class="markdown-document">${content}</main></body>
229
+ <body><main class="markdown-document">${input.html}</main></body>
197
230
  </html>`;
198
231
  };
199
232
 
233
+ export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string => {
234
+ if (typeof input.markdown !== "string" || !input.markdown.trim()) {
235
+ throw new MarkdownPdfError("bad_input", "Markdown must not be empty.", "markdown_empty");
236
+ }
237
+ return buildPresetPdfHtml({ html: renderMarkdown(input.markdown), templateId: input.templateId, customCss: input.customCss });
238
+ };
239
+
200
240
  export const renderMarkdownToPdfWithConfig = (
201
241
  input: RenderMarkdownToPdfInput,
202
242
  config: GotenbergConfig,
203
243
  options: RenderMarkdownToPdfOptions = {},
204
- ): Promise<RenderHtmlToPdfResult> => renderHtmlToPdfWithConfig({ html: buildMarkdownPdfHtml(input) }, config, options);
244
+ ): Promise<RenderHtmlToPdfResult> => renderHtmlToPdfWithConfig({ html: buildMarkdownPdfHtml(input), title: input.title }, config, options);
205
245
 
206
246
  export const renderMarkdownToPdf = async (
207
247
  input: RenderMarkdownToPdfInput,
@@ -13,6 +13,16 @@
13
13
  * MathML (`math`) and the SVG elements that hold HTML (`foreignObject`,
14
14
  * `desc`) go as well; they have no print use in Cloud documents. SVG shapes,
15
15
  * images, `title`, and links stay, so graphics still render.
16
+ *
17
+ * Chromium takes the PDF title from the document's first `<title>`. Without
18
+ * one it falls back to the random name Gotenberg gives the uploaded file,
19
+ * which PDF viewers then show. A caller title fills that gap: it goes ahead
20
+ * of the document, so it becomes the first title, unless the document names
21
+ * itself with a non-blank HTML `<title>`. SVG titles label graphics, template
22
+ * content stays inert, and removed elements take their titles with them, so
23
+ * none of them count. A title of only whitespace character references, such
24
+ * as `&#32;`, still counts and leaves the random name; generated HTML does not
25
+ * write whitespace that way.
16
26
  */
17
27
  const POLICY =
18
28
  "default-src 'none'; style-src 'unsafe-inline' file: data:; img-src file: data:; font-src file: data:; base-uri 'none'; form-action 'none'";
@@ -53,13 +63,48 @@ const leadingDoctype = (html: string): string => {
53
63
  /** An empty comment stands in for a removed element, so the text on either side stays separate. */
54
64
  const REMOVED = "<!---->";
55
65
 
56
- export const offlineHtml = (html: string): string =>
57
- leadingDoctype(html) +
58
- OFFLINE_META +
59
- new HTMLRewriter()
60
- // Chromium parses noscript content as markup when JavaScript is disabled.
61
- // math, foreignObject, and desc: see the module comment.
62
- .on("script, noscript, meta, base, iframe, frame, frameset, object, embed, math, foreignObject, desc", {
66
+ // Chromium parses noscript content as markup when JavaScript is disabled.
67
+ // math, foreignObject, and desc: see the module comment.
68
+ const REMOVED_ELEMENTS = [
69
+ "script",
70
+ "noscript",
71
+ "meta",
72
+ "base",
73
+ "iframe",
74
+ "frame",
75
+ "frameset",
76
+ "object",
77
+ "embed",
78
+ "math",
79
+ "foreignObject",
80
+ "desc",
81
+ ];
82
+ const INERT_TITLES = ["svg", "template", ...REMOVED_ELEMENTS].map((element) => `${element} title`).join(", ");
83
+
84
+ const escapeTitle = (title: string): string => title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
85
+
86
+ export const offlineHtml = (html: string, title?: string): string => {
87
+ // Only the text of the first HTML title element names the document.
88
+ let current: { inert: boolean } | undefined;
89
+ let firstSeen = false;
90
+ let named = false;
91
+ const offline = new HTMLRewriter()
92
+ .on("title", {
93
+ element() {
94
+ if (current && !current.inert) firstSeen = true;
95
+ current = { inert: false };
96
+ },
97
+ text(chunk) {
98
+ if (!firstSeen && !current?.inert && chunk.text.trim()) named = true;
99
+ },
100
+ })
101
+ // Runs after the title handler for the same element, in registration order.
102
+ .on(INERT_TITLES, {
103
+ element() {
104
+ if (current) current.inert = true;
105
+ },
106
+ })
107
+ .on(REMOVED_ELEMENTS.join(", "), {
63
108
  element(element) {
64
109
  element.replace(REMOVED, { html: true });
65
110
  },
@@ -72,3 +117,6 @@ export const offlineHtml = (html: string): string =>
72
117
  },
73
118
  })
74
119
  .transform(html);
120
+ const fallbackTitle = title?.trim() && !named ? `<title>${escapeTitle(title.trim())}</title>` : "";
121
+ return leadingDoctype(html) + OFFLINE_META + fallbackTitle + offline;
122
+ };
@@ -39,6 +39,8 @@ export type RenderTemplatePdfPreviewInput = {
39
39
  data: Record<string, unknown>;
40
40
  filters?: LiquidTemplateOptions["filters"];
41
41
  filename?: string;
42
+ /** PDF document title when the rendered HTML has no `<title>`; see `RenderHtmlToPdfInput.title`. */
43
+ title?: string;
42
44
  };
43
45
 
44
46
  export type RenderTemplatePdfPreviewOptions = RenderHtmlToPdfOptions & {
@@ -89,7 +91,7 @@ export const renderTemplatePdfPreview = async (
89
91
  }
90
92
 
91
93
  try {
92
- const pdfInput = { html, headerHtml, footerHtml, filename: input.filename };
94
+ const pdfInput = { html, title: input.title, headerHtml, footerHtml, filename: input.filename };
93
95
  return {
94
96
  ok: true,
95
97
  html,
@@ -317,6 +317,8 @@ export const CORE_SETTINGS = {
317
317
  default: "",
318
318
  description: "Internal base URL of the Gotenberg service used for HTML-to-PDF rendering.",
319
319
  placeholder: "e.g. http://gotenberg:3000",
320
+ envFallback: () => env.GOTENBERG_URL,
321
+ envBootstrap: () => env.GOTENBERG_URL,
320
322
  },
321
323
  "gotenberg.username": {
322
324
  kind: "string",
@@ -21,8 +21,9 @@ import { taskListExtension } from "./extensions/task-list";
21
21
 
22
22
  // Create a configured marked instance
23
23
  type MarkdownProfile = "content" | "help";
24
+ type LinkStyle = "widget" | "plain";
24
25
 
25
- const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle = "card") => {
26
+ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle = "card", links: LinkStyle = "widget") => {
26
27
  const marked = new Marked();
27
28
 
28
29
  marked.use({
@@ -35,7 +36,8 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
35
36
  marked.use(infoBlocksExtension(notices));
36
37
  marked.use(taskListExtension());
37
38
  marked.use(tablesExtension());
38
- marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank" }));
39
+ // Plain links keep marked's own renderer: an anchor around the link text.
40
+ if (links === "widget") marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank" }));
39
41
  marked.use(imagesExtension());
40
42
  marked.use(katexExtension());
41
43
  marked.use(codeExtension());
@@ -48,15 +50,24 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
48
50
  };
49
51
 
50
52
  const marked = createMarked();
51
- const minimalNoticeMarked = createMarked("content", "minimal");
52
53
  const helpMarked = createMarked("help");
54
+ const contentMarked = new Map<`${NoticeStyle}:${LinkStyle}`, Marked>([["card:widget", marked]]);
53
55
 
54
56
  export type MarkdownRenderOptions = {
55
57
  /** `"minimal"` shows notices as tone colour only; the type name stays for screen readers. */
56
58
  notices?: NoticeStyle;
59
+ /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
60
+ links?: LinkStyle;
57
61
  };
58
62
 
59
- const markedFor = (options: MarkdownRenderOptions): Marked => (options.notices === "minimal" ? minimalNoticeMarked : marked);
63
+ const markedFor = ({ notices = "card", links = "widget" }: MarkdownRenderOptions): Marked => {
64
+ const key = `${notices}:${links}` as const;
65
+ const cached = contentMarked.get(key);
66
+ if (cached) return cached;
67
+ const created = createMarked("content", notices, links);
68
+ contentMarked.set(key, created);
69
+ return created;
70
+ };
60
71
 
61
72
  const sanitizeRenderedHtml = (html: string): string =>
62
73
  sanitizeHtml(html, {
@@ -1,4 +1,4 @@
1
- import { ButtonLink } from "@k2b/ui";
1
+ import { ButtonLink, IconButtonLink } from "@k2b/ui";
2
2
  import type { NavigationSearchItem } from "../browser/navigation-search";
3
3
  import type { CloudTheme } from "../shared/theme";
4
4
  import AppLaunchpad, { type AppLaunchpadApp } from "./AppLaunchpad.island";
@@ -34,17 +34,13 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
34
34
  style="box-shadow: var(--ui-shadow-surface)"
35
35
  >
36
36
  <div class="flex min-w-0 items-center gap-2">
37
- <a
38
- href="/"
39
- class={
40
- props.authenticated
41
- ? "inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-dimmed transition-colors hover:bg-zinc-100 hover:text-secondary lg:hidden dark:hover:bg-zinc-800"
42
- : "flex shrink-0 items-center"
43
- }
44
- aria-label={props.homeLabel}
45
- >
46
- <img src="/branding/logo" alt="" class={props.authenticated ? "h-4 w-4" : "h-6 w-6"} />
47
- </a>
37
+ {/* Signed-in desktops reach Home through the rail. `lg:hidden` sits on a wrapper because the button's
38
+ own display would win over the utility on the link. */}
39
+ <div class={props.authenticated ? "flex shrink-0 lg:hidden" : "flex shrink-0"}>
40
+ <IconButtonLink href="/" label={props.homeLabel}>
41
+ <img src="/branding/logo" alt="" class={props.authenticated ? "h-4 w-4" : "h-6 w-6"} />
42
+ </IconButtonLink>
43
+ </div>
48
44
  <div class="hidden min-w-0 items-center lg:flex">
49
45
  <LayoutBreadcrumbs breadcrumbs={props.breadcrumbs} />
50
46
  </div>
@@ -66,40 +62,22 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
66
62
  searchLinks={props.searchLinks}
67
63
  searchResources={props.authenticated}
68
64
  />
65
+ {(props.authenticated || props.appLabel) && (
66
+ <AppLaunchpad
67
+ profile={props.authenticated ? { name: props.profileName, theme: props.theme } : undefined}
68
+ apps={props.launchpadApps}
69
+ legalLinks={props.legalLinks}
70
+ variant="header"
71
+ label={props.openAppsLabel}
72
+ />
73
+ )}
69
74
  </div>
70
75
  {props.authenticated ? (
71
- <>
72
- <div class="lg:hidden">
73
- <AppLaunchpad
74
- profile={props.authenticated ? { name: props.profileName, theme: props.theme } : undefined}
75
- apps={props.launchpadApps}
76
- legalLinks={props.legalLinks}
77
- variant="header"
78
- label={props.openAppsLabel}
79
- />
80
- </div>
81
- <div class="hidden lg:block">
82
- <ProfilePreferences
83
- avatarSrc={props.profileAvatarSrc}
84
- initialTheme={props.theme}
85
- name={props.profileName}
86
- placement="header"
87
- />
88
- </div>
89
- </>
76
+ <div class="hidden lg:block">
77
+ <ProfilePreferences avatarSrc={props.profileAvatarSrc} initialTheme={props.theme} name={props.profileName} placement="header" />
78
+ </div>
90
79
  ) : (
91
80
  <>
92
- {props.appLabel && (
93
- <div class="lg:hidden">
94
- <AppLaunchpad
95
- profile={props.authenticated ? { name: props.profileName, theme: props.theme } : undefined}
96
- apps={props.launchpadApps}
97
- legalLinks={props.legalLinks}
98
- variant="header"
99
- label={props.openAppsLabel}
100
- />
101
- </div>
102
- )}
103
81
  <LayoutPreferences initialTheme={props.theme} position="bottom-left" />
104
82
  <ButtonLink href="/auth/login" size="sm" variant="primary">
105
83
  <i class="ti ti-login" aria-hidden="true" />
@@ -3,10 +3,8 @@ import type { CloudTheme } from "../shared/theme";
3
3
  import { createPreferenceController } from "./preference-controller";
4
4
 
5
5
  type LayoutPreferencesProps = {
6
- class?: string;
7
6
  initialTheme: CloudTheme;
8
7
  position: DropdownPosition;
9
- triggerClass?: string;
10
8
  };
11
9
 
12
10
  export default function LayoutPreferences(props: LayoutPreferencesProps) {
@@ -14,21 +12,8 @@ export default function LayoutPreferences(props: LayoutPreferencesProps) {
14
12
  const preferences = createPreferenceController(props.initialTheme, locale);
15
13
 
16
14
  return (
17
- <Dropdown.Root
18
- class={props.class}
19
- items={preferences.items()}
20
- label={preferences.messages().preferencesMenuLabel}
21
- position={props.position}
22
- width="14rem"
23
- >
24
- <Dropdown.Trigger
25
- class={props.triggerClass}
26
- iconOnly
27
- label={preferences.messages().preferencesMenuLabel}
28
- size="sm"
29
- tooltip={false}
30
- variant="secondary"
31
- >
15
+ <Dropdown.Root items={preferences.items()} label={preferences.messages().preferencesMenuLabel} position={props.position} width="14rem">
16
+ <Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" tooltip={false} variant="secondary">
32
17
  <i class="ti ti-adjustments-horizontal" aria-hidden="true" />
33
18
  </Dropdown.Trigger>
34
19
  </Dropdown.Root>