@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.
- package/package.json +13 -13
- package/scripts/build.ts +3 -1
- package/scripts/sync-recovery-smoke.ts +1 -4
- package/src/_internal/define-app.ts +6 -1
- package/src/_internal/process-sync.ts +16 -8
- package/src/_internal/status-preserving-ssr.ts +9 -1
- package/src/_internal/sync-budget.ts +306 -0
- package/src/ai/code-mode-skill.ts +3 -3
- package/src/ai/html-pdf-tool.ts +2 -1
- package/src/ai/markdown-pdf-tool.ts +9 -1
- package/src/config/env.ts +5 -0
- package/src/server/locale.ts +17 -4
- package/src/server/middleware/validator.ts +23 -1
- package/src/services/gateway.ts +5 -0
- package/src/services/index.ts +2 -0
- package/src/services/pdf/gotenberg.ts +9 -2
- package/src/services/pdf/index.ts +2 -0
- package/src/services/pdf/markdown.ts +53 -13
- package/src/services/pdf/offline-html.ts +55 -7
- package/src/services/pdf/template-preview.ts +3 -1
- package/src/services/settings/core-settings.ts +2 -0
- package/src/shared/markdown/index.ts +15 -4
- package/src/ssr/LayoutHeader.tsx +20 -42
- package/src/ssr/LayoutPreferences.island.tsx +2 -17
- package/src/ssr/MinimalLayout.tsx +44 -20
- package/src/ssr/MinimalLayoutPreferences.island.tsx +48 -0
- package/src/ssr/PageError.tsx +1 -1
- package/src/ssr/profile-preferences-messages.ts +4 -0
- package/src/ssr/workspace-navigation.ts +3 -1
- package/src/styles/utilities-navigation.css +69 -20
|
@@ -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(
|
|
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: "",
|
package/src/server/locale.ts
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
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)
|
|
34
|
+
if (!candidate) continue;
|
|
35
|
+
if (CATALOG_LANGUAGES.has(new Intl.Locale(candidate).language)) return candidate;
|
|
36
|
+
firstValid ??= candidate;
|
|
24
37
|
}
|
|
25
|
-
return
|
|
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.
|
package/src/services/gateway.ts
CHANGED
|
@@ -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,
|
package/src/services/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
187
|
-
const
|
|
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">${
|
|
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 ` `, 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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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
|
|
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 = (
|
|
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, {
|
package/src/ssr/LayoutHeader.tsx
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
<
|
|
73
|
-
|
|
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
|
-
|
|
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>
|