pgkiln 0.84.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +14 -5
  4. package/docs/guide/04-pages-and-regions.md +1 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/09-reference.md +1 -0
  8. package/docs/guide/12-development.md +10 -1
  9. package/examples/gallery/README.md +23 -6
  10. package/examples/gallery/gallery.json +4 -0
  11. package/examples/gallery/samples/sample-approvals.sql +102 -11
  12. package/examples/gallery/samples/sample-calendar.sql +40 -5
  13. package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
  14. package/examples/gallery/samples/sample-grids.sql +35 -3
  15. package/examples/gallery/samples/sample-maps.sql +65 -7
  16. package/examples/gallery/samples/sample-processes.sql +466 -0
  17. package/examples/gallery/samples/sample-validations.sql +407 -0
  18. package/examples/gallery/samples/ux-patterns.sql +154 -14
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_80_notifications.sql +125 -0
  21. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  22. package/package.json +1 -1
  23. package/public/app.css +8 -1
  24. package/public/app.js +28 -9
  25. package/src/app.ts +9 -0
  26. package/src/builder/ai-agents.ts +86 -85
  27. package/src/builder/ai-builder.ts +30 -30
  28. package/src/builder/ai.ts +93 -92
  29. package/src/builder/api.ts +47 -47
  30. package/src/builder/app-groups.ts +33 -32
  31. package/src/builder/appfromfile.ts +57 -58
  32. package/src/builder/appsheets.ts +33 -32
  33. package/src/builder/appwizard.ts +65 -65
  34. package/src/builder/arrange.ts +23 -22
  35. package/src/builder/assistant.ts +39 -38
  36. package/src/builder/automations.ts +32 -31
  37. package/src/builder/availability.ts +24 -24
  38. package/src/builder/backups.ts +30 -31
  39. package/src/builder/blueprints.ts +62 -61
  40. package/src/builder/create-page.ts +52 -50
  41. package/src/builder/cross-app.ts +37 -35
  42. package/src/builder/dataload.ts +2 -2
  43. package/src/builder/datasets.ts +107 -106
  44. package/src/builder/dependencies.ts +20 -19
  45. package/src/builder/designer.ts +36 -36
  46. package/src/builder/diagnostics.ts +58 -57
  47. package/src/builder/features.ts +39 -35
  48. package/src/builder/feedback.ts +23 -20
  49. package/src/builder/find-workspace.ts +28 -24
  50. package/src/builder/form-designer.ts +36 -35
  51. package/src/builder/gallery.ts +25 -29
  52. package/src/builder/globalization.ts +50 -49
  53. package/src/builder/i18n/de.ts +3453 -0
  54. package/src/builder/i18n/en.ts +3453 -0
  55. package/src/builder/i18n/es.ts +3453 -0
  56. package/src/builder/i18n/fr.ts +3453 -0
  57. package/src/builder/i18n/it.ts +3453 -0
  58. package/src/builder/i18n/ja.ts +3453 -0
  59. package/src/builder/i18n/ko.ts +3453 -0
  60. package/src/builder/i18n/nl.ts +3453 -0
  61. package/src/builder/i18n/pt.ts +3453 -0
  62. package/src/builder/i18n/zh-cn.ts +3453 -0
  63. package/src/builder/i18n/zh-tw.ts +3453 -0
  64. package/src/builder/i18n.ts +17 -3
  65. package/src/builder/json-sources.ts +63 -62
  66. package/src/builder/layouts.ts +12 -11
  67. package/src/builder/locks.ts +29 -28
  68. package/src/builder/login-page.ts +26 -25
  69. package/src/builder/mail.ts +148 -147
  70. package/src/builder/newapp.ts +15 -12
  71. package/src/builder/plugins.ts +47 -44
  72. package/src/builder/process-jobs.ts +11 -10
  73. package/src/builder/pwa.ts +22 -21
  74. package/src/builder/remote-databases.ts +55 -56
  75. package/src/builder/report-settings.ts +1 -1
  76. package/src/builder/reporter.ts +43 -42
  77. package/src/builder/rest-catalogs.ts +87 -86
  78. package/src/builder/rest.ts +9 -8
  79. package/src/builder/routes.ts +27 -27
  80. package/src/builder/search.ts +14 -13
  81. package/src/builder/static-files.ts +31 -32
  82. package/src/builder/subscriptions.ts +47 -45
  83. package/src/builder/supporting.ts +23 -18
  84. package/src/builder/team.ts +179 -179
  85. package/src/builder/telemetry.ts +59 -62
  86. package/src/builder/templates.ts +66 -65
  87. package/src/builder/themeroller.ts +60 -63
  88. package/src/builder/ui-defaults.ts +21 -20
  89. package/src/builder/ui.ts +2 -1
  90. package/src/builder/upgrade.ts +25 -24
  91. package/src/builder/utilities.ts +48 -47
  92. package/src/builder/vector-providers.ts +67 -66
  93. package/src/builder/websources.ts +43 -42
  94. package/src/builder/wizards.ts +95 -89
  95. package/src/builder/workflow-migrate.ts +30 -31
  96. package/src/builder/workflows.ts +40 -39
  97. package/src/builder/workingcopies.ts +41 -49
  98. package/src/builder/workspace-backups.ts +21 -20
  99. package/src/builder/workspace-mail.ts +17 -16
  100. package/src/builder/workspace-resources.ts +104 -112
  101. package/src/builder/workspace-transfer.ts +34 -26
  102. package/src/runtime/engine.ts +6 -3
  103. package/src/runtime/lists.ts +2 -2
  104. package/src/runtime/push.ts +7 -1
  105. package/src/runtime/regions.ts +9 -4
@@ -7,9 +7,10 @@ import { html } from '../html.ts';
7
7
  import { icon } from '../icons.ts';
8
8
  import { readMultipart } from '../runtime/files.ts';
9
9
  import { clientIp, logActivity, type Session } from '../session.ts';
10
- import { describeWorkspaceFile, exportWorkspace, importWorkspace, PART_LABELS, PARTS, type Part } from '../workspace-export.ts';
10
+ import { describeWorkspaceFile, exportWorkspace, importWorkspace, PARTS, type Part } from '../workspace-export.ts';
11
11
  import { back, BASE, csrf, developer, flash, region, send, shell, type Body, type Req } from './ui.ts';
12
12
  import { currentWorkspace, workspaceAdmin } from './workspaces.ts';
13
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
13
14
 
14
15
  // Workspace utilities → Export / import (APEX: Workspace Utilities → Export):
15
16
  // the current workspace's components as one file, and such a file imported
@@ -19,7 +20,14 @@ import { currentWorkspace, workspaceAdmin } from './workspaces.ts';
19
20
 
20
21
  const RESULT = '__WS_IMPORT_RESULT';
21
22
  const page = `${BASE}/workspace-export`;
22
- const crumbs: [string, string?][] = [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Export and import']];
23
+ const crumbs = (): [string, string?][] => [[tx('rail.app_builder'), BASE], [tx('wu.title'), `${BASE}/utilities`], [tx('wu.export')]];
24
+ /** The kinds of component in the developer's language (PART_LABELS in English: src/workspace-export.ts). */
25
+ const PART_KEYS: Record<Part, BuilderKey> = {
26
+ settings: 'wtr.part.settings', developers: 'wtr.part.developers', app_groups: 'wtr.part.app_groups', extension_links: 'wtr.part.extension_links',
27
+ rest_catalogs: 'wtr.part.rest_catalogs', sql_scripts: 'wtr.part.sql_scripts', blueprints: 'wtr.part.blueprints', data_generators: 'wtr.part.data_generators',
28
+ datasets: 'wtr.part.datasets', team: 'wtr.part.team', workspace_files: 'wtr.part.workspace_files', remote_servers: 'wtr.part.remote_servers',
29
+ web_credentials: 'wtr.part.web_credentials',
30
+ };
23
31
  const MAX_MB = 20;
24
32
 
25
33
  let version: string | null | undefined;
@@ -41,16 +49,16 @@ const chosen = (b: Body | undefined): Part[] => {
41
49
 
42
50
  async function adminOr403(s: Session, reply: FastifyReply) {
43
51
  if (s.workspace && (await workspaceAdmin(s.username, s.workspace.id))) return true;
44
- await send(reply.code(403), s, shell(s, 'Export and import', crumbs, html`<h1 class="u-mb1">Export and import</h1>
45
- <div class="alert alert-error" role="alert">Only administrators and the workspace's administrators export or import a workspace's components.</div>`));
52
+ await send(reply.code(403), s, shell(s, tx('wu.export'), crumbs(), html`<h1 class="u-mb1">${tx('wu.export')}</h1>
53
+ <div class="alert alert-error" role="alert">${tx('wtr.admins_only')}</div>`));
46
54
  return false;
47
55
  }
48
56
 
49
57
  const partBoxes = (counts?: Partial<Record<Part, number>>) =>
50
- html`<fieldset class="field" data-wide><legend class="label">Components</legend>${PARTS.map((p) =>
58
+ html`<fieldset class="field" data-wide><legend class="label">${tx('wtr.components')}</legend>${PARTS.map((p) =>
51
59
  counts && counts[p] === undefined
52
60
  ? ''
53
- : html`<label class="check"><input type="checkbox" name="part" value="${p}" checked> ${PART_LABELS[p]}${counts ? html` <span class="muted">(${p === 'settings' ? 'in the file' : p === 'team' ? `${counts[p]} issue(s)` : counts[p]})</span>` : ''}</label>`)}</fieldset>`;
61
+ : html`<label class="check"><input type="checkbox" name="part" value="${p}" checked> ${tx(PART_KEYS[p])}${counts ? html` <span class="muted">(${p === 'settings' ? tx('wtr.in_file') : p === 'team' ? tx('wtr.issues', { n: counts[p] }) : counts[p]})</span>` : ''}</label>`)}</fieldset>`;
54
62
 
55
63
  export async function workspaceTransferRoutes(app: FastifyInstance) {
56
64
  app.get(page, async (req: Req, reply) => {
@@ -59,22 +67,22 @@ export async function workspaceTransferRoutes(app: FastifyInstance) {
59
67
  const result = s.state[RESULT] ? (JSON.parse(s.state[RESULT]!) as string[]) : null;
60
68
  delete s.state[RESULT];
61
69
  const name = s.workspace!.name;
62
- const main = html`<div class="title-row"><h1>Export and import</h1></div>
63
- <p class="muted u-mt0">The components of the workspace ${name} as one file: to copy them to another workspace or installation, or to keep them with your applications' files. Applications are exported on their own (App Builder → an application → Export).</p>
64
- ${result ? html`<div class="alert alert-success" role="status"><b>Imported into ${name}:</b><ul>${result.map((l) => html`<li>${l}</li>`)}</ul></div>` : ''}
65
- ${region('Export', html`<form method="post" action="${page}">${csrf(s)}
70
+ const main = html`<div class="title-row"><h1>${tx('wu.export')}</h1></div>
71
+ <p class="muted u-mt0">${tx('wtr.intro', { name })}</p>
72
+ ${result ? html`<div class="alert alert-success" role="status"><b>${tx('wtr.imported_into', { name })}</b><ul>${result.map((l) => html`<li>${l}</li>`)}</ul></div>` : ''}
73
+ ${region(tx('wtr.export'), html`<form method="post" action="${page}">${csrf(s)}
66
74
  ${partBoxes()}
67
- <p class="muted">Not in the file: the workspace's name, its isolation and database roles, passwords and secrets, script runs and other history.</p>
68
- <div class="buttons"><button class="btn btn-hot">${icon('download')} Export</button></div></form>`)}
69
- ${region('Import', html`<form method="post" action="${BASE}/workspace-import" enctype="multipart/form-data">${csrf(s)}
70
- <p class="muted u-mt0">A file exported from a workspace, imported into <b>${name}</b>. Nothing is deleted; developers are added only when this installation has a developer of that name.</p>
75
+ <p class="muted">${tx('wtr.not_in_file')}</p>
76
+ <div class="buttons"><button class="btn btn-hot">${icon('download')} ${tx('wtr.export')}</button></div></form>`)}
77
+ ${region(tx('wtr.import'), html`<form method="post" action="${BASE}/workspace-import" enctype="multipart/form-data">${csrf(s)}
78
+ <p class="muted u-mt0">${tx.rich('wtr.import_intro', { name: html`<b>${name}</b>` })}</p>
71
79
  <div class="form-grid">
72
- <div class="field" data-wide><label class="label" for="f_file">Workspace file</label><input id="f_file" type="file" name="file" accept=".json,application/json" required></div>
80
+ <div class="field" data-wide><label class="label" for="f_file">${tx('wtr.file')}</label><input id="f_file" type="file" name="file" accept=".json,application/json" required></div>
73
81
  ${partBoxes()}
74
- <div class="field" data-wide><label class="check"><input type="checkbox" name="replace" value="true"> Replace components with the same name (otherwise this workspace's own are kept)</label></div>
82
+ <div class="field" data-wide><label class="check"><input type="checkbox" name="replace" value="true"> ${tx('wtr.replace')}</label></div>
75
83
  </div>
76
- <div class="buttons"><button class="btn">${icon('upload')} Import</button></div></form>`)}`;
77
- return send(reply, s, shell(s, 'Export and import', crumbs, main));
84
+ <div class="buttons"><button class="btn">${icon('upload')} ${tx('wtr.import')}</button></div></form>`)}`;
85
+ return send(reply, s, shell(s, tx('wu.export'), crumbs(), main));
78
86
  });
79
87
 
80
88
  app.post(page, async (req: Req, reply) => {
@@ -82,7 +90,7 @@ export async function workspaceTransferRoutes(app: FastifyInstance) {
82
90
  if (!s || !(await adminOr403(s, reply))) return;
83
91
  const parts = chosen(req.body);
84
92
  if (!parts.length) {
85
- flash(s, 'Choose at least one kind of component.', 'error');
93
+ flash(s, tx('wtr.choose_part'), 'error');
86
94
  return back(reply, s, page);
87
95
  }
88
96
  const doc = await exportWorkspace(currentWorkspace(s), parts, { version: pgkilnVersion() ?? undefined, by: s.username });
@@ -104,23 +112,23 @@ export async function workspaceTransferRoutes(app: FastifyInstance) {
104
112
  const s = await developer(req, reply);
105
113
  if (!s || !(await adminOr403(s, reply))) return;
106
114
  try {
107
- if (tooBig) throw new Error(`The file is larger than ${MAX_MB} MB.`);
108
- if (!text.trim()) throw new Error('Choose a workspace file.');
115
+ if (tooBig) throw new Error(tx('wtr.too_big', { n: MAX_MB }));
116
+ if (!text.trim()) throw new Error(tx('wtr.choose_file'));
109
117
  let doc: unknown;
110
118
  try {
111
119
  doc = JSON.parse(text);
112
120
  } catch {
113
- throw new Error('The file is not valid JSON.');
121
+ throw new Error(tx('wtr.not_json'));
114
122
  }
115
123
  describeWorkspaceFile(doc);
116
124
  const parts = chosen(req.body);
117
- if (!parts.length) throw new Error('Choose at least one kind of component.');
125
+ if (!parts.length) throw new Error(tx('wtr.choose_part'));
118
126
  const ws = currentWorkspace(s);
119
127
  const result = await owner.tx((c) => importWorkspace(c, ws, doc, parts, { replace: req.body?.replace === 'true', by: s.username }));
120
128
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `imported into workspace ${ws}: ${result.added} added, ${result.replaced} replaced, ${result.skipped} kept` });
121
129
  s.state[RESULT] = JSON.stringify(result.lines);
122
130
  } catch (e) {
123
- flash(s, `Import failed, nothing was changed: ${friendly(e)}`, 'error');
131
+ flash(s, tx('wtr.failed', { reason: friendly(e) }), 'error');
124
132
  }
125
133
  return back(reply, s, page);
126
134
  });
@@ -129,8 +137,8 @@ export async function workspaceTransferRoutes(app: FastifyInstance) {
129
137
  /** A database refusal in words (the transaction rolled back). */
130
138
  function friendly(e: unknown) {
131
139
  const err = e as { message: string; constraint?: string; table?: string; code?: string };
132
- if (err.code === '23514' || err.code === '23502' || err.code === '22P02') return `the file has a value this installation refuses (${err.table ?? ''}${err.constraint ? `: ${err.constraint}` : ''}).`;
133
- if (err.code === '23505') return `the file has two components with the same name (${err.constraint ?? err.table ?? ''}).`;
140
+ if (err.code === '23514' || err.code === '23502' || err.code === '22P02') return tx('wtr.refused_value', { detail: `${err.table ?? ''}${err.constraint ? `: ${err.constraint}` : ''}` });
141
+ if (err.code === '23505') return tx('wtr.duplicate', { detail: err.constraint ?? err.table ?? '' });
134
142
  return err.message;
135
143
  }
136
144
 
@@ -301,9 +301,10 @@ export async function validate(ctx: PageContext) {
301
301
  };
302
302
  try {
303
303
  const outcome = await validationPasses(ctx, v, value);
304
- if (outcome !== true) report(typeof outcome === 'string' ? outcome : v.message);
304
+ // (166) &ITEM. in the message, as in APEX (plain text: the message is escaped where it is shown)
305
+ if (outcome !== true) report(typeof outcome === 'string' ? outcome : substitute(v.message, ctx, (x) => x));
305
306
  } catch (e) {
306
- report(`${v.message} (${await publicError(ctx, e, `validation ${v.name}`)})`);
307
+ report(`${substitute(v.message, ctx, (x) => x)} (${await publicError(ctx, e, `validation ${v.name}`)})`);
307
308
  }
308
309
  }
309
310
 
@@ -683,7 +684,9 @@ export async function runProcesses(ctx: PageContext, point: 'submit' | 'load') {
683
684
  continue;
684
685
  }
685
686
  const msg = await runOne(ctx, p, names, 0);
686
- if (msg) messages.push(msg);
687
+ // (166) &ITEM. in a success message, with the values after the process (a new row's key, say), as in APEX;
688
+ // once here, so a chain's messages (joined by runOne) are filled in once
689
+ if (msg) messages.push(substitute(msg, ctx, (x) => x));
687
690
  }
688
691
  return messages;
689
692
  }
@@ -231,9 +231,9 @@ export function navbarMarkup(nodes: ListNode[], label: string, live?: string): R
231
231
  if (!nodes.length) return raw('');
232
232
  return html`<nav class="t-navbar" aria-label="${label}"><ul>${nodes.map((n) =>
233
233
  n.children.length
234
- ? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
234
+ ? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
235
235
  [...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c, live)}</a></li>`)}</ul></div></details></li>`
236
- : html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
236
+ : html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
237
237
  }
238
238
 
239
239
  function linksMarkup(nodes: ListNode[]): Raw {
@@ -12,6 +12,8 @@ import { loadContext, type Req } from './routes.ts';
12
12
  // action push_subscribe: both after a click, as browsers require) and posts
13
13
  // it here. The application's public key reaches the page as data-push.
14
14
 
15
+ let pushKeyWarned = false;
16
+
15
17
  /** Whether the app offers notifications. */
16
18
  export const pushOn = (a: Pick<App, 'pwa' | 'pwa_push'>) => a.pwa && a.pwa_push;
17
19
 
@@ -22,7 +24,11 @@ export async function pushBody(a: Pick<App, 'id' | 'pwa' | 'pwa_push'>, username
22
24
  return { 'data-push': await appPublicKey(a.id), 'data-push-user': username.toLowerCase() };
23
25
  } catch (e) {
24
26
  // no key yet and no PGKILN_SECRET_KEY to store one: the page works, without notifications
25
- console.error('push notifications:', (e as Error).message);
27
+ // (166) said once per server, not on every page
28
+ if (!pushKeyWarned) {
29
+ pushKeyWarned = true;
30
+ console.error('push notifications:', (e as Error).message);
31
+ }
26
32
  return {};
27
33
  }
28
34
  }
@@ -481,9 +481,12 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
481
481
  </dialog>`;
482
482
 
483
483
  const header = html`<h2 id="${titleId}">${r.title}</h2>${buttonsOnTop ? buttons : ''}`;
484
+ // (166) the header buttons sit beside the summary, not in it: a summary is the toggle, and a button inside it is
485
+ // nested interactive content (axe); they come first so they are reached before the region's body
484
486
  if (r.template === 'collapsible')
485
- return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
486
- <details open><summary class="region-header">${header}</summary>
487
+ return html`<section class="${cls}${buttonsOnTop && buttons ? ' has-header-buttons' : ''}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
488
+ ${buttonsOnTop && buttons ? html`<div class="region-header-buttons">${buttons}</div>` : ''}
489
+ <details open><summary class="region-header"><h2 id="${titleId}">${r.title}</h2></summary>
487
490
  <div class="region-body"${scrollable(r)}>${body}</div>
488
491
  ${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
489
492
  </details></section>`;
@@ -518,9 +521,11 @@ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, bo
518
521
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-label="${r.title}">
519
522
  ${top ? html`<div class="region-toolbar">${top}</div>` : ''}${body}${footer}</section>`;
520
523
  const header = html`<h2 id="${titleId}">${r.title}</h2>${top}`;
524
+ // (166) as above: the header buttons beside the summary, not in it
521
525
  if (r.template === 'collapsible')
522
- return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
523
- <details open><summary class="region-header">${header}</summary><div class="region-body"${scrollable(r)}>${body}</div>${footer}</details></section>`;
526
+ return html`<section class="${cls}${top ? ' has-header-buttons' : ''}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
527
+ ${top ? html`<div class="region-header-buttons">${top}</div>` : ''}
528
+ <details open><summary class="region-header"><h2 id="${titleId}">${r.title}</h2></summary><div class="region-body"${scrollable(r)}>${body}</div>${footer}</details></section>`;
524
529
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
525
530
  ${r.title || top ? html`<header class="region-header">${header}</header>` : ''}
526
531
  <div class="region-body"${scrollable(r)}>${body}</div>${footer}