@k2b/cloud 0.18.0 → 0.19.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 +2 -2
- package/src/_internal/define-app.ts +4 -1
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/html-pdf-tool.ts +2 -1
- package/src/ai/markdown-pdf-tool.ts +9 -1
- package/src/services/pdf/gotenberg.ts +9 -2
- package/src/services/pdf/markdown.ts +3 -1
- package/src/services/pdf/offline-html.ts +55 -7
- package/src/services/pdf/template-preview.ts +3 -1
- package/src/shared/markdown/index.ts +15 -4
- package/src/ssr/LayoutHeader.tsx +20 -42
- package/src/ssr/workspace-navigation.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@tailwindcss/typography": "0.5.20",
|
|
100
100
|
"@k2b/nessi": "0.12.1",
|
|
101
101
|
"@k2b/ssr": "0.14.0",
|
|
102
|
-
"@k2b/ui": "0.
|
|
102
|
+
"@k2b/ui": "0.9.0",
|
|
103
103
|
"@k2b/stdlib": "0.26.0",
|
|
104
104
|
"@k2b/sync": "6.5.0",
|
|
105
105
|
"@nats-io/transport-node": "3.4.0",
|
|
@@ -341,6 +341,9 @@ export const defineApp = <
|
|
|
341
341
|
basePath: opts.basePath,
|
|
342
342
|
template: ({ body, scripts, title, description, theme, lang, performanceRoute }) => {
|
|
343
343
|
const themeFixed = theme !== undefined;
|
|
344
|
+
// The inline layer statement fixes the cascade order before any
|
|
345
|
+
// stylesheet declares a layer. Tailwind's `properties` layer resets
|
|
346
|
+
// `--tw-*` in browsers without `@property` and must stay lowest.
|
|
344
347
|
return `<!DOCTYPE html>
|
|
345
348
|
<html lang="${normalizeLocale(lang)}" class="${theme ?? "light"}"${themeFixed ? " data-theme-fixed" : ""}>
|
|
346
349
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
@@ -350,7 +353,7 @@ export const defineApp = <
|
|
|
350
353
|
<meta name="theme-color" content="#09090b">
|
|
351
354
|
<meta name="mobile-web-app-capable" content="yes">
|
|
352
355
|
<link rel="icon" href="${appFaviconHref(opts.id, v)}">
|
|
353
|
-
<style data-cloud-css-layers>@layer theme, base, components, utilities;</style>
|
|
356
|
+
<style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
|
|
354
357
|
<link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
|
355
358
|
<link rel="stylesheet" href="/public/fonts.css?v=${v}">
|
|
356
359
|
<link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
|
|
@@ -4,7 +4,7 @@ import type { AiSkillTemplate } from "./skills";
|
|
|
4
4
|
|
|
5
5
|
export const ASSISTANT_CODE_MODE_SKILL = {
|
|
6
6
|
"key": "assistant:code-mode",
|
|
7
|
-
"version":
|
|
7
|
+
"version": 55,
|
|
8
8
|
"name": "assistant-code-mode",
|
|
9
9
|
"description": "Inspect and transform unfamiliar data, analyze files, compare results across Cloud apps, or build and improve interactive and agent-only Apps in Assistant Studio. Use for quick code experiments, data analysis, file generation, resource SQL queries and combining discovered Cloud capabilities. For plain arithmetic or date offsets, answer directly or use calculate.",
|
|
10
10
|
"instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, save one in Files, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
|
|
@@ -88,7 +88,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
|
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
"path": "references/pdf.md",
|
|
91
|
-
"content": "# Generate PDFs\n\n`pdf.render`, `pdf.attach` and `pdf.facturX` are asynchronous and return a PDF\n`Blob`. They use the instance's configured PDF service. `pdf.open` is\nthe local text reader described in [Documents](documents.md).\n\n## Choose the path\n\nA document written in the chat needs no code. Use the chat tool\n`markdown_to_pdf` for text-first documents; it applies A4 presets and custom CSS\nand turns images into links. Use `html_to_pdf` for a chat `.html` file whose\nlayout needs HTML and CSS, images, or fonts. It takes optional CSS (file or\ninline), header and footer files, chat files as named assets, and the `page`\noptions below, then writes a sibling `.pdf` for `present`. Use `pdf.render` when\ncode builds the document from data, for Factur-X or attachments, and in Studio\nApps.\n\n## HTML and CSS\n\n```js\nconst document = await pdf.render({\n html: `<!doctype html><html><head><style>\n body { font-family: sans-serif; }\n h1 { color: #087f70; }\n tr { break-inside: avoid; }\n </style></head><body><h1>Stock report</h1><img src=\"logo.png\"></body></html>`,\n assets: [{ name: \"logo.png\", data: logoFile }],\n page: { format: \"A4\", landscape: false, margin: { top: 15, right: 15, bottom: 15, left: 15 } },\n tagged: true,\n});\nawait files.save(document, \"stock-report.pdf\");\n```\n\n`html` is required. `assets` defaults to an empty array and accepts named `Blob`\nvalues for local images, fonts and CSS. Use plain filenames, no directories;\nreference the exact filename from HTML or CSS. Duplicate names and the reserved\nnames `index.html`, `header.html`, `footer.html`, `factur-x.xml` fail.\n`headerHtml` and `footerHtml` are optional independent HTML strings with their own\nCSS. They load no assets; use `data:` URLs for images there. Page markers such as\n`<span class=\"pageNumber\"></span>` work in those templates, and the page margin\nmust leave room for them. Background colors are printed.\n\n`page.format` defaults to `A4`; alternatives are `A3`, `A5`, `Letter`, and `Legal`.\n`landscape` defaults to false. Each margin is a nonnegative millimeter number,\ndefaulting to 15. Use `page` for paper dimensions and margins; avoid conflicting\nCSS `@page` rules. `tagged` defaults to true, which requests a tagged PDF but does\nnot certify accessibility.\n\nStudio styles are not inherited. Scripts, redirects, frames and outbound\nresources are blocked. MathML (`math`) and the SVG elements `foreignObject` and\n`desc` are removed; write formulas and labels as HTML and CSS or as SVG text.\nSupply local assets or data URLs; this is not a URL-to-PDF browser or a\nJavaScript rendering environment.\n\n## Attach files\n\n```js\nconst result = await pdf.attach({\n document,\n attachments: [{\n name: \"details.xml\",\n data: new Blob([xml], { type: \"application/xml\" }),\n relationship: \"Data\",\n }],\n});\nawait files.shared.write(\"reports/with-details.pdf\", result);\n```\n\nThe source PDF and attachments are ordinary `Blob`s. Their origin does not\nmatter: explicit picker selections, authorized chat inputs, or app storage use\nthe same API. `relationship` defaults to `Unspecified`; alternatives are\n`Source`, `Data`, `Alternative`, and `Supplement`. MIME type comes from the Blob\nand defaults to `application/octet-stream` if empty. Provide at least one attachment. Names within the request\nmust be unique. All asset/attachment names are 1–180 characters, with no slash,\nbackslash or control characters, and cannot be `.` or `..`. Embedding an XML file alone does not create a compliant invoice.\n\n## Factur-X / ZUGFeRD\n\n```js\nconst checked = einvoice.validate(invoice);\nif (!checked.ok) throw new Error(JSON.stringify(checked.error));\nconst xml = einvoice.serialize(checked.data, { format: \"zugferd-2.5-en16931\" });\nif (!xml.ok) throw new Error(JSON.stringify(xml.error));\nconst document = await pdf.facturX({\n html: invoiceHtml,\n xml: xml.data.xml,\n profile: \"EN 16931\",\n});\nawait files.save(document, \"invoice.pdf\");\n```\n\n`facturX` accepts the same render options plus required `xml` and `profile`.\nProfiles: `MINIMUM`, `BASIC WL`, `BASIC`, `EN 16931`, `EXTENDED`. Use `EN 16931`\nwith the bundled `einvoice.serialize` output; that serializer does not support\nthe other profiles. The service embeds `factur-x.xml`, sets Factur-X 1.0 invoice\nmetadata and requests PDF/A-3b. The app must supply matching HTML and XML.\nNeither rendering nor parsing certifies XSD, Schematron, tax or invoice validity.\n\n## Save a PDF in Files\n\nA chat PDF stays in the chat until code writes it elsewhere. To save it in the\nuser's Files, pass its chat path in `code_run.inputPaths` and write it through\nthe discovered `filesv2.content.create` action:\n\n```js\nexport default async () => {\n const document = await files.read(\"/offer.pdf\");\n const target = await capabilities.run(\"filesv2.content.create\", {\n baseId: \"<exact ID from filesv2.bases.list>\",\n path: \"Offers/offer.pdf\",\n size: document.size,\n mediaType: \"application/pdf\",\n });\n return capabilities.streams.write(target.stream, document);\n};\n```\n\nAsk for the storage base and folder when the request does not name them. The user\nreviews the write. It creates a new file and fails when the path exists;\nreplacing requires the current `expectedRevision`. A `pdf.render` result can be\nwritten the same way without saving it to the chat first. See\n[Capability calls](capabilities.md) for stream limits and interrupted writes.\n\n## Cancellation, access and limits\n\nAll three methods accept a second `{ signal }` argument, for example the signal\nfrom a `work.run` job. Abort rejects with `AbortError`. Stopping the execution\nhost also cancels pending PDF requests. Rendering creates no stored file until\ncode explicitly saves it; do not automatically retry failed calls.\n\nSaved resources need Use access, not Manage. One-off scripts need an accessible,\nunrestricted current chat. The server checks access before reading the body.\nNo service URL, credentials, shell flags or arbitrary conversion route are\nexposed to app code. This is an internal conversion, not `http.fetch`; there is\nno external API approval prompt.\n\nConfigured service input, output and timeout limits apply. HTML, its headers,\nfooters, assets and invoice XML share the HTML input budget. PDF attachments\nand the source PDF share the PDF input budget. All transfers also have a 64 MiB\nceiling; multipart framing has a separate bounded overhead. Shared storage and\nchat export budgets remain independent. Errors include `PDF_NOT_CONFIGURED`,\n`PDF_LIMIT`, `PDF_TIMEOUT`, `PDF_FAILED`, `INVALID_INPUT`, and `ACCESS_DENIED`.\n"
|
|
91
|
+
"content": "# Generate PDFs\n\n`pdf.render`, `pdf.attach` and `pdf.facturX` are asynchronous and return a PDF\n`Blob`. They use the instance's configured PDF service. `pdf.open` is\nthe local text reader described in [Documents](documents.md).\n\n## Choose the path\n\nA document written in the chat needs no code. Use the chat tool\n`markdown_to_pdf` for text-first documents; it applies A4 presets and custom CSS\nand turns images into links. Use `html_to_pdf` for a chat `.html` file whose\nlayout needs HTML and CSS, images, or fonts. It takes optional CSS (file or\ninline), header and footer files, chat files as named assets, and the `page`\noptions below, then writes a sibling `.pdf` for `present`. Use `pdf.render` when\ncode builds the document from data, for Factur-X or attachments, and in Studio\nApps.\n\n## HTML and CSS\n\n```js\nconst document = await pdf.render({\n html: `<!doctype html><html><head><title>Stock report</title><style>\n body { font-family: sans-serif; }\n h1 { color: #087f70; }\n tr { break-inside: avoid; }\n </style></head><body><h1>Stock report</h1><img src=\"logo.png\"></body></html>`,\n assets: [{ name: \"logo.png\", data: logoFile }],\n page: { format: \"A4\", landscape: false, margin: { top: 15, right: 15, bottom: 15, left: 15 } },\n tagged: true,\n});\nawait files.save(document, \"stock-report.pdf\");\n```\n\n`html` is required. Give it a `<title>`: PDF viewers show it as the document\nname, and without one they show a random file name. `assets` defaults to an empty array and accepts named `Blob`\nvalues for local images, fonts and CSS. Use plain filenames, no directories;\nreference the exact filename from HTML or CSS. Duplicate names and the reserved\nnames `index.html`, `header.html`, `footer.html`, `factur-x.xml` fail.\n`headerHtml` and `footerHtml` are optional independent HTML strings with their own\nCSS. They load no assets; use `data:` URLs for images there. Page markers such as\n`<span class=\"pageNumber\"></span>` work in those templates, and the page margin\nmust leave room for them. Background colors are printed.\n\n`page.format` defaults to `A4`; alternatives are `A3`, `A5`, `Letter`, and `Legal`.\n`landscape` defaults to false. Each margin is a nonnegative millimeter number,\ndefaulting to 15. Use `page` for paper dimensions and margins; avoid conflicting\nCSS `@page` rules. `tagged` defaults to true, which requests a tagged PDF but does\nnot certify accessibility.\n\nStudio styles are not inherited. Scripts, redirects, frames and outbound\nresources are blocked. MathML (`math`) and the SVG elements `foreignObject` and\n`desc` are removed; write formulas and labels as HTML and CSS or as SVG text.\nSupply local assets or data URLs; this is not a URL-to-PDF browser or a\nJavaScript rendering environment.\n\n## Attach files\n\n```js\nconst result = await pdf.attach({\n document,\n attachments: [{\n name: \"details.xml\",\n data: new Blob([xml], { type: \"application/xml\" }),\n relationship: \"Data\",\n }],\n});\nawait files.shared.write(\"reports/with-details.pdf\", result);\n```\n\nThe source PDF and attachments are ordinary `Blob`s. Their origin does not\nmatter: explicit picker selections, authorized chat inputs, or app storage use\nthe same API. `relationship` defaults to `Unspecified`; alternatives are\n`Source`, `Data`, `Alternative`, and `Supplement`. MIME type comes from the Blob\nand defaults to `application/octet-stream` if empty. Provide at least one attachment. Names within the request\nmust be unique. All asset/attachment names are 1–180 characters, with no slash,\nbackslash or control characters, and cannot be `.` or `..`. Embedding an XML file alone does not create a compliant invoice.\n\n## Factur-X / ZUGFeRD\n\n```js\nconst checked = einvoice.validate(invoice);\nif (!checked.ok) throw new Error(JSON.stringify(checked.error));\nconst xml = einvoice.serialize(checked.data, { format: \"zugferd-2.5-en16931\" });\nif (!xml.ok) throw new Error(JSON.stringify(xml.error));\nconst document = await pdf.facturX({\n html: invoiceHtml,\n xml: xml.data.xml,\n profile: \"EN 16931\",\n});\nawait files.save(document, \"invoice.pdf\");\n```\n\n`facturX` accepts the same render options plus required `xml` and `profile`.\nProfiles: `MINIMUM`, `BASIC WL`, `BASIC`, `EN 16931`, `EXTENDED`. Use `EN 16931`\nwith the bundled `einvoice.serialize` output; that serializer does not support\nthe other profiles. The service embeds `factur-x.xml`, sets Factur-X 1.0 invoice\nmetadata and requests PDF/A-3b. The app must supply matching HTML and XML.\nNeither rendering nor parsing certifies XSD, Schematron, tax or invoice validity.\n\n## Save a PDF in Files\n\nA chat PDF stays in the chat until code writes it elsewhere. To save it in the\nuser's Files, pass its chat path in `code_run.inputPaths` and write it through\nthe discovered `filesv2.content.create` action:\n\n```js\nexport default async () => {\n const document = await files.read(\"/offer.pdf\");\n const target = await capabilities.run(\"filesv2.content.create\", {\n baseId: \"<exact ID from filesv2.bases.list>\",\n path: \"Offers/offer.pdf\",\n size: document.size,\n mediaType: \"application/pdf\",\n });\n return capabilities.streams.write(target.stream, document);\n};\n```\n\nAsk for the storage base and folder when the request does not name them. The user\nreviews the write. It creates a new file and fails when the path exists;\nreplacing requires the current `expectedRevision`. A `pdf.render` result can be\nwritten the same way without saving it to the chat first. See\n[Capability calls](capabilities.md) for stream limits and interrupted writes.\n\n## Cancellation, access and limits\n\nAll three methods accept a second `{ signal }` argument, for example the signal\nfrom a `work.run` job. Abort rejects with `AbortError`. Stopping the execution\nhost also cancels pending PDF requests. Rendering creates no stored file until\ncode explicitly saves it; do not automatically retry failed calls.\n\nSaved resources need Use access, not Manage. One-off scripts need an accessible,\nunrestricted current chat. The server checks access before reading the body.\nNo service URL, credentials, shell flags or arbitrary conversion route are\nexposed to app code. This is an internal conversion, not `http.fetch`; there is\nno external API approval prompt.\n\nConfigured service input, output and timeout limits apply. HTML, its headers,\nfooters, assets and invoice XML share the HTML input budget. PDF attachments\nand the source PDF share the PDF input budget. All transfers also have a 64 MiB\nceiling; multipart framing has a separate bounded overhead. Shared storage and\nchat export budgets remain independent. Errors include `PDF_NOT_CONFIGURED`,\n`PDF_LIMIT`, `PDF_TIMEOUT`, `PDF_FAILED`, `INVALID_INPUT`, and `ACCESS_DENIED`.\n"
|
|
92
92
|
},
|
|
93
93
|
{
|
|
94
94
|
"path": "references/publishing.md",
|
package/src/ai/html-pdf-tool.ts
CHANGED
|
@@ -100,7 +100,7 @@ export const createCloudAiHtmlToPdfTool = (dependencies: HtmlPdfToolDependencies
|
|
|
100
100
|
return defineAiTool({
|
|
101
101
|
name: "html_to_pdf",
|
|
102
102
|
description:
|
|
103
|
-
'Convert one assistant-created conversation HTML file to a sibling PDF; the output path replaces .html with .pdf. Use it when the layout needs HTML and CSS, such as columns, exact tables, letterheads, invoices, certificates, images, or custom fonts; use markdown_to_pdf for text-first documents. Write the .html source with write_file first, appending long files in parts; CSS, header, footer, and asset files can be any conversation files, including uploads. Rendering is offline without JavaScript: scripts, frames, and remote URLs are removed or blocked, and MathML and the SVG foreignObject and desc elements are removed, so write formulas and labels as HTML and CSS or as SVG text. Embed images and fonts as data: URLs, or list conversation files in assets and reference each by its file name only, for example <img src="logo.png"> or url("brand.woff2"); percent-encode #, ?, %, and : in a name, such as logo%232.png for logo#2.png. cssPath and customCss apply after the document\'s own styles. page sets paper size (default A4), orientation, and millimeter margins (default 15); CSS @page sizes are ignored. Header and footer files are small separate HTML documents with their own inline styles; they load no assets, so use data: URLs for images there. <span class="pageNumber"></span> and <span class="totalPages"></span> print page numbers, and the top or bottom margin must leave room for them. All input files share the configured PDF input budget. Call present with the returned path. To also save the PDF in Files, follow the PDF reference of the assistant-code-mode skill.',
|
|
103
|
+
'Convert one assistant-created conversation HTML file to a sibling PDF; the output path replaces .html with .pdf. Use it when the layout needs HTML and CSS, such as columns, exact tables, letterheads, invoices, certificates, images, or custom fonts; use markdown_to_pdf for text-first documents. Write the .html source with write_file first, appending long files in parts; CSS, header, footer, and asset files can be any conversation files, including uploads. Rendering is offline without JavaScript: scripts, frames, and remote URLs are removed or blocked, and MathML and the SVG foreignObject and desc elements are removed, so write formulas and labels as HTML and CSS or as SVG text. Embed images and fonts as data: URLs, or list conversation files in assets and reference each by its file name only, for example <img src="logo.png"> or url("brand.woff2"); percent-encode #, ?, %, and : in a name, such as logo%232.png for logo#2.png. cssPath and customCss apply after the document\'s own styles. page sets paper size (default A4), orientation, and millimeter margins (default 15); CSS @page sizes are ignored. Header and footer files are small separate HTML documents with their own inline styles; they load no assets, so use data: URLs for images there. <span class="pageNumber"></span> and <span class="totalPages"></span> print page numbers, and the top or bottom margin must leave room for them. The HTML <title> names the PDF in viewers; without one, the file name does. All input files share the configured PDF input budget. Call present with the returned path. To also save the PDF in Files, follow the PDF reference of the assistant-code-mode skill.',
|
|
104
104
|
inputSchema: CloudAiHtmlToPdfInputSchema,
|
|
105
105
|
outputSchema: CloudAiHtmlToPdfOutputSchema,
|
|
106
106
|
approval: "never",
|
|
@@ -158,6 +158,7 @@ export const createCloudAiHtmlToPdfTool = (dependencies: HtmlPdfToolDependencies
|
|
|
158
158
|
// Like Code Mode, the document renders in standards mode. Separate CSS
|
|
159
159
|
// follows the document, so it wins over the document's own styles.
|
|
160
160
|
html: `<!doctype html>${source}${css ? `\n<style>\n${css}\n</style>\n` : ""}`,
|
|
161
|
+
title: basename(sourcePath).replace(HTML_EXTENSION, ""),
|
|
161
162
|
headerHtml,
|
|
162
163
|
footerHtml,
|
|
163
164
|
assets,
|
|
@@ -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;
|
|
@@ -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"]);
|
|
@@ -30,6 +30,8 @@ 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
|
};
|
|
@@ -201,7 +203,7 @@ export const renderMarkdownToPdfWithConfig = (
|
|
|
201
203
|
input: RenderMarkdownToPdfInput,
|
|
202
204
|
config: GotenbergConfig,
|
|
203
205
|
options: RenderMarkdownToPdfOptions = {},
|
|
204
|
-
): Promise<RenderHtmlToPdfResult> => renderHtmlToPdfWithConfig({ html: buildMarkdownPdfHtml(input) }, config, options);
|
|
206
|
+
): Promise<RenderHtmlToPdfResult> => renderHtmlToPdfWithConfig({ html: buildMarkdownPdfHtml(input), title: input.title }, config, options);
|
|
205
207
|
|
|
206
208
|
export const renderMarkdownToPdf = async (
|
|
207
209
|
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,
|
|
@@ -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" />
|
|
@@ -49,12 +49,14 @@ export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined
|
|
|
49
49
|
try {
|
|
50
50
|
const value: unknown = JSON.parse(owner.textContent ?? "");
|
|
51
51
|
if (!value || typeof value !== "object" || !("items" in value) || !validItems(value.items)) return;
|
|
52
|
-
// SSR links work before the app island loads; action handlers are not yet available
|
|
52
|
+
// SSR links work before the app island loads; action and disclosure handlers are not yet available,
|
|
53
|
+
// so the owner's folds only set where the local disclosure starts.
|
|
53
54
|
const links = (items: readonly NavigationItem[]): NavigationItem[] =>
|
|
54
55
|
items.map((item) => ({
|
|
55
56
|
...item,
|
|
56
57
|
disabled: item.disabled || Boolean(item.action && !item.href),
|
|
57
58
|
...(item.href ? { action: undefined } : {}),
|
|
59
|
+
...(item.expanded !== undefined ? { expanded: undefined, defaultExpanded: item.expanded } : {}),
|
|
58
60
|
...(item.children ? { children: links(item.children) } : {}),
|
|
59
61
|
...(item.actions ? { actions: links(item.actions) } : {}),
|
|
60
62
|
}));
|