domma-cms 0.55.1 → 0.66.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 (211) hide show
  1. package/CLAUDE.md +130 -2
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. package/plugins/todo/data/todos.json +0 -1
@@ -1,16 +1,82 @@
1
1
  {
2
- "name": "invoice-issuers",
3
- "label": "Invoice Issuers",
4
2
  "slug": "invoice-issuers",
5
- "storage": { "adapter": "file" },
3
+ "title": "Invoice Issuers",
4
+ "description": "Businesses that issue invoices, managed by the Invoice plugin.",
5
+ "plugin": "invoice",
6
+ "storage": {
7
+ "adapter": "file"
8
+ },
6
9
  "fields": [
7
- { "key": "name", "label": "Trading Name", "type": "text", "required": true },
8
- { "key": "email", "label": "Email", "type": "text" },
9
- { "key": "phone", "label": "Phone", "type": "text" },
10
- { "key": "website", "label": "Website", "type": "text" },
11
- { "key": "address", "label": "Address", "type": "textarea" },
12
- { "key": "vatNumber", "label": "VAT Number", "type": "text" },
13
- { "key": "bankDetails", "label": "Bank Details", "type": "textarea" },
14
- { "key": "logoUrl", "label": "Logo URL", "type": "text" }
10
+ {
11
+ "name": "name",
12
+ "label": "Trading Name",
13
+ "type": "text",
14
+ "required": true
15
+ },
16
+ {
17
+ "name": "email",
18
+ "label": "Email",
19
+ "type": "text"
20
+ },
21
+ {
22
+ "name": "phone",
23
+ "label": "Phone",
24
+ "type": "text"
25
+ },
26
+ {
27
+ "name": "website",
28
+ "label": "Website",
29
+ "type": "text"
30
+ },
31
+ {
32
+ "name": "address",
33
+ "label": "Address",
34
+ "type": "textarea"
35
+ },
36
+ {
37
+ "name": "vatNumber",
38
+ "label": "VAT Number",
39
+ "type": "text"
40
+ },
41
+ {
42
+ "name": "accountName",
43
+ "label": "Account Name",
44
+ "type": "text"
45
+ },
46
+ {
47
+ "name": "bankName",
48
+ "label": "Bank",
49
+ "type": "text"
50
+ },
51
+ {
52
+ "name": "sortCode",
53
+ "label": "Sort Code",
54
+ "type": "text"
55
+ },
56
+ {
57
+ "name": "accountNumber",
58
+ "label": "Account Number",
59
+ "type": "text"
60
+ },
61
+ {
62
+ "name": "iban",
63
+ "label": "IBAN",
64
+ "type": "text"
65
+ },
66
+ {
67
+ "name": "bic",
68
+ "label": "BIC / SWIFT",
69
+ "type": "text"
70
+ },
71
+ {
72
+ "name": "bankDetails",
73
+ "label": "Other Payment Notes",
74
+ "type": "textarea"
75
+ },
76
+ {
77
+ "name": "logoUrl",
78
+ "label": "Logo URL",
79
+ "type": "text"
80
+ }
15
81
  ]
16
82
  }
@@ -1,15 +1,16 @@
1
1
  {
2
- "name": "invoice-receivers",
3
- "label": "Invoice Receivers",
4
2
  "slug": "invoice-receivers",
3
+ "title": "Invoice Receivers",
4
+ "description": "Clients invoices are billed to, managed by the Invoice plugin.",
5
+ "plugin": "invoice",
5
6
  "storage": { "adapter": "file" },
6
7
  "fields": [
7
- { "key": "name", "label": "Name", "type": "text", "required": true },
8
- { "key": "company", "label": "Company", "type": "text" },
9
- { "key": "email", "label": "Email", "type": "text" },
10
- { "key": "phone", "label": "Phone", "type": "text" },
11
- { "key": "address", "label": "Address", "type": "textarea" },
12
- { "key": "vatNumber", "label": "VAT Number", "type": "text" },
13
- { "key": "reference", "label": "Reference", "type": "text" }
8
+ { "name": "name", "label": "Name", "type": "text", "required": true },
9
+ { "name": "company", "label": "Company", "type": "text" },
10
+ { "name": "email", "label": "Email", "type": "text" },
11
+ { "name": "phone", "label": "Phone", "type": "text" },
12
+ { "name": "address", "label": "Address", "type": "textarea" },
13
+ { "name": "vatNumber", "label": "VAT Number", "type": "text" },
14
+ { "name": "reference", "label": "Reference", "type": "text" }
14
15
  ]
15
16
  }
@@ -1,15 +1,16 @@
1
1
  {
2
- "name": "invoices",
3
- "label": "Invoices",
4
2
  "slug": "invoices",
3
+ "title": "Invoices",
4
+ "description": "Invoices managed by the Invoice plugin.",
5
+ "plugin": "invoice",
5
6
  "storage": { "adapter": "file" },
6
7
  "fields": [
7
- { "key": "number", "label": "Number", "type": "text", "required": true },
8
- { "key": "issuerId", "label": "Issuer", "type": "text", "required": true },
9
- { "key": "receiverId", "label": "Receiver", "type": "text", "required": true },
10
- { "key": "issueDate", "label": "Issue Date", "type": "date", "required": true },
11
- { "key": "dueDate", "label": "Due Date", "type": "date" },
12
- { "key": "status", "label": "Status", "type": "select", "required": true,
8
+ { "name": "number", "label": "Number", "type": "text", "required": true },
9
+ { "name": "issuerId", "label": "Issuer", "type": "text", "required": true },
10
+ { "name": "receiverId", "label": "Receiver", "type": "text", "required": true },
11
+ { "name": "issueDate", "label": "Issue Date", "type": "date", "required": true },
12
+ { "name": "dueDate", "label": "Due Date", "type": "date" },
13
+ { "name": "status", "label": "Status", "type": "select", "required": true,
13
14
  "options": [
14
15
  { "value": "draft", "label": "Draft" },
15
16
  { "value": "sent", "label": "Sent" },
@@ -18,10 +19,15 @@
18
19
  { "value": "cancelled", "label": "Cancelled" }
19
20
  ]
20
21
  },
21
- { "key": "currency", "label": "Currency", "type": "text" },
22
- { "key": "vatEnabled", "label": "VAT Enabled", "type": "boolean" },
23
- { "key": "vatRate", "label": "VAT Rate %", "type": "number" },
24
- { "key": "lineItems", "label": "Line Items", "type": "json" },
25
- { "key": "notes", "label": "Notes", "type": "textarea" }
22
+ { "name": "currency", "label": "Currency", "type": "text" },
23
+ { "name": "vatEnabled", "label": "VAT Enabled", "type": "boolean" },
24
+ { "name": "vatRate", "label": "VAT Rate %", "type": "number" },
25
+ { "name": "lineItems", "label": "Line Items", "type": "json" },
26
+ { "name": "notes", "label": "Notes", "type": "textarea" },
27
+ { "name": "sentAt", "label": "Last Sent", "type": "datetime" },
28
+ { "name": "sentTo", "label": "Sent To", "type": "text" },
29
+ { "name": "sendCount", "label": "Times Sent", "type": "number" },
30
+ { "name": "payments", "label": "Payments", "type": "json" },
31
+ { "name": "archived", "label": "Archived", "type": "boolean" }
26
32
  ]
27
33
  }
@@ -1,16 +1,37 @@
1
1
  /**
2
2
  * Invoice Plugin - Default Configuration
3
3
  *
4
- * numberPrefix used in generated invoice numbers (e.g. INV-2026-0001)
5
- * numberPadding minimum digits for the sequential portion
6
- * defaultVatRate applied when VAT is enabled and no rate is set on the invoice
7
- * defaultCurrency ISO 4217 code; controls formatting + symbol
8
- * footerNote optional small print appended to every printed invoice
4
+ * Everything here is editable at runtime from the cog in the plugin's banner
5
+ * (Settings > Defaults / Numbering / Email / Branding), which writes to
6
+ * config/plugins.json under `invoice.settings`. These are the fallbacks for a
7
+ * key that has never been set.
8
+ *
9
+ * numberPrefix used in generated invoice numbers (e.g. INV-2026-0001)
10
+ * numberPadding minimum digits for the sequential portion
11
+ * defaultVatRate applied when VAT is enabled and no rate is set on the invoice
12
+ * defaultCurrency ISO 4217 code; controls formatting + symbol
13
+ * vatEnabledByDefault whether a new invoice starts with VAT switched on
14
+ * defaultPaymentTermsDays fills a new invoice's due date; 0 = leave it blank
15
+ * defaultIssuerId the issuer a new invoice opens with; '' = none
16
+ * logoUrl house logo, used for any issuer without one of its own
17
+ * template print layout: classic | modern | minimal (Settings > Templates)
18
+ * footerNote optional small print appended to every printed invoice
19
+ * emailSubject subject line; {{number}} {{issuer}} {{total}} {{dueDate}}
20
+ * emailIntro the covering note above the invoice summary; same placeholders
21
+ * attachPdf attach a rendered PDF when the host can make one
9
22
  */
10
23
  export default {
11
24
  numberPrefix: 'INV',
12
25
  numberPadding: 4,
13
26
  defaultVatRate: 20,
14
27
  defaultCurrency: 'GBP',
15
- footerNote: 'Thank you for your business.'
28
+ vatEnabledByDefault: true,
29
+ defaultPaymentTermsDays: 30,
30
+ defaultIssuerId: '',
31
+ logoUrl: '',
32
+ template: 'classic',
33
+ footerNote: 'Thank you for your business.',
34
+ emailSubject: 'Invoice {{number}} from {{issuer}}',
35
+ emailIntro: 'Please find invoice {{number}} attached, for {{total}}.',
36
+ attachPdf: true
16
37
  };
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Invoice Plugin - server-side PDF.
3
+ *
4
+ * The printable HTML already exists and is the thing customers see, so the PDF
5
+ * is that same page rendered by a real browser rather than a second layout
6
+ * maintained in a drawing library. Chrome is the only renderer that agrees with
7
+ * what the Print button shows.
8
+ *
9
+ * `puppeteer-core` is an OPTIONAL dependency, the way `mongodb` is: it is pure
10
+ * JavaScript and installs anywhere, but it does not bring a browser with it -
11
+ * that is the whole point of -core. A host with no Chrome keeps working and the
12
+ * admin simply does not offer the button, rather than the install growing a
13
+ * few hundred megabytes for a feature many sites never use.
14
+ *
15
+ * The browser is launched per request and closed in a finally. A warm instance
16
+ * would save roughly a second, at the cost of a permanently resident Chrome on
17
+ * every site in a fleet - the wrong trade for a button pressed a few times a day.
18
+ */
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import {fileURLToPath} from 'url';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const CMS_ROOT = path.resolve(__dirname, '..', '..');
25
+
26
+ /** Where a Chrome might be, most specific first. CHROME_BIN wins. */
27
+ const CHROME_CANDIDATES = [
28
+ process.env.CHROME_BIN,
29
+ process.env.PUPPETEER_EXECUTABLE_PATH,
30
+ '/usr/bin/google-chrome',
31
+ '/usr/bin/google-chrome-stable',
32
+ '/usr/bin/chromium',
33
+ '/usr/bin/chromium-browser',
34
+ '/snap/bin/chromium'
35
+ ].filter(Boolean);
36
+
37
+ /**
38
+ * Chrome installs puppeteer downloads for itself, newest first. puppeteer-core
39
+ * does not download one, but a host that also has `puppeteer` (or ran
40
+ * `npx puppeteer browsers install`) has one sitting here.
41
+ *
42
+ * @returns {string[]}
43
+ */
44
+ function cachedChromes() {
45
+ const roots = [
46
+ process.env.PUPPETEER_CACHE_DIR,
47
+ path.join(process.env.HOME || '', '.cache', 'puppeteer')
48
+ ].filter(Boolean);
49
+
50
+ const found = [];
51
+ for (const root of roots) {
52
+ for (const kind of ['chrome', 'chrome-headless-shell']) {
53
+ const dir = path.join(root, kind);
54
+ let builds;
55
+ try {
56
+ builds = fs.readdirSync(dir).sort().reverse();
57
+ } catch {
58
+ continue;
59
+ }
60
+ for (const build of builds) {
61
+ for (const rel of [
62
+ ['chrome-linux64', 'chrome'],
63
+ ['chrome-headless-shell-linux64', 'chrome-headless-shell']
64
+ ]) {
65
+ const bin = path.join(dir, build, ...rel);
66
+ if (fs.existsSync(bin)) found.push(bin);
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return found;
72
+ }
73
+
74
+ let cached = null;
75
+
76
+ /**
77
+ * Can this host render a PDF, and if not, why not?
78
+ *
79
+ * Cached after the first call - the answer cannot change without a restart, and
80
+ * the admin asks on every editor mount.
81
+ *
82
+ * @param {boolean} [refresh]
83
+ * @returns {Promise<{available: boolean, chrome: string|null, reason: string|null}>}
84
+ */
85
+ export async function pdfCapability(refresh = false) {
86
+ if (cached && !refresh) return cached;
87
+
88
+ let puppeteer = null;
89
+ try {
90
+ puppeteer = await import('puppeteer-core');
91
+ } catch {
92
+ cached = {
93
+ available: false,
94
+ chrome: null,
95
+ reason: 'puppeteer-core is not installed. It is an optional dependency: npm install puppeteer-core'
96
+ };
97
+ return cached;
98
+ }
99
+
100
+ const chrome = [...CHROME_CANDIDATES, ...cachedChromes()].find(p => {
101
+ try { return fs.existsSync(p); } catch { return false; }
102
+ });
103
+
104
+ cached = chrome
105
+ ? { available: true, chrome, reason: null, _puppeteer: puppeteer }
106
+ : {
107
+ available: false,
108
+ chrome: null,
109
+ reason: 'No Chrome executable found. Install one and set CHROME_BIN, or run: npx puppeteer browsers install chrome'
110
+ };
111
+ return cached;
112
+ }
113
+
114
+ /**
115
+ * Render a full HTML document to an A4 PDF.
116
+ *
117
+ * The HTML is set directly rather than served, so the print template's relative
118
+ * URLs would not resolve - `base` gives them something to resolve against, and
119
+ * `waitUntil: 'networkidle0'` is what makes a remote logo actually appear
120
+ * instead of racing the screenshot.
121
+ *
122
+ * @param {string} html
123
+ * @param {object} [opts]
124
+ * @param {string} [opts.base] absolute URL for relative assets
125
+ * @param {number} [opts.timeoutMs]
126
+ * @returns {Promise<Buffer>}
127
+ * @throws {Error} when no renderer is available
128
+ */
129
+ export async function renderPdf(html, { base = null, timeoutMs = 30000 } = {}) {
130
+ const cap = await pdfCapability();
131
+ if (!cap.available) throw new Error(cap.reason);
132
+
133
+ const puppeteer = cap._puppeteer ?? await import('puppeteer-core');
134
+ const launch = puppeteer.default?.launch ?? puppeteer.launch;
135
+
136
+ const browser = await launch.call(puppeteer.default ?? puppeteer, {
137
+ executablePath: cap.chrome,
138
+ headless: true,
139
+ // No usable sandbox in a typical container or on a bare VPS, and the
140
+ // error when it is missing does not name it.
141
+ args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu']
142
+ });
143
+
144
+ try {
145
+ const page = await browser.newPage();
146
+ page.setDefaultTimeout(timeoutMs);
147
+ const doc = base && !/<base\s/i.test(html)
148
+ ? html.replace(/<head([^>]*)>/i, `<head$1><base href="${base}">`)
149
+ : html;
150
+ await page.setContent(doc, { waitUntil: 'networkidle0', timeout: timeoutMs });
151
+ // The template already carries @page/@media print rules.
152
+ await page.emulateMediaType('print');
153
+ return await page.pdf({
154
+ format: 'A4',
155
+ printBackground: true,
156
+ preferCSSPageSize: true,
157
+ margin: { top: '0mm', right: '0mm', bottom: '0mm', left: '0mm' }
158
+ });
159
+ } finally {
160
+ await browser.close().catch(() => {});
161
+ }
162
+ }
163
+
164
+ export { CMS_ROOT };