@jwilleke/ngdpbase-types 0.0.0 → 5.18.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 (302) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +16 -2
  3. package/lib/packages/ngdpbase-types/index.d.ts +32 -0
  4. package/lib/packages/ngdpbase-types/testing.d.ts +43 -0
  5. package/lib/packages/ngdpbase-types/testing.js +53 -0
  6. package/lib/src/cache/CacheDependencies.d.ts +50 -0
  7. package/lib/src/cache/CacheDependencies.js +50 -0
  8. package/lib/src/cache/ICacheAdapter.d.ts +86 -0
  9. package/lib/src/cache/RegionCache.d.ts +171 -0
  10. package/lib/src/context/ActorContext.d.ts +48 -0
  11. package/lib/src/context/ActorContext.js +33 -0
  12. package/lib/src/context/ApiContext.d.ts +194 -0
  13. package/lib/src/context/ApiContext.js +273 -0
  14. package/lib/src/context/BaseContext.d.ts +110 -0
  15. package/lib/src/context/BaseContext.js +143 -0
  16. package/lib/src/context/JobContext.d.ts +153 -0
  17. package/lib/src/context/JobContext.js +144 -0
  18. package/lib/src/context/WikiContext.d.ts +404 -0
  19. package/lib/src/context/bootActions.d.ts +64 -0
  20. package/lib/src/context/bootActions.js +111 -0
  21. package/lib/src/converters/HtmlConverter.d.ts +70 -0
  22. package/lib/src/converters/IContentConverter.d.ts +94 -0
  23. package/lib/src/converters/JSPWikiConverter.d.ts +117 -0
  24. package/lib/src/converters/conversionWarning.d.ts +15 -0
  25. package/lib/src/converters/ncm/bridge.d.ts +22 -0
  26. package/lib/src/converters/ncm/existing.d.ts +23 -0
  27. package/lib/src/converters/ncm/fix/blocks.d.ts +89 -0
  28. package/lib/src/converters/ncm/fix/bulletMarkers.d.ts +21 -0
  29. package/lib/src/converters/ncm/fix/index.d.ts +36 -0
  30. package/lib/src/converters/ncm/fix/jspwikiBullets.d.ts +20 -0
  31. package/lib/src/converters/ncm/fix/jspwikiCodeMarkers.d.ts +38 -0
  32. package/lib/src/converters/ncm/fix/jspwikiHeadings.d.ts +25 -0
  33. package/lib/src/converters/ncm/fix/jspwikiIndent.d.ts +24 -0
  34. package/lib/src/converters/ncm/fix/jspwikiInsert.d.ts +21 -0
  35. package/lib/src/converters/ncm/fix/jspwikiItalic.d.ts +29 -0
  36. package/lib/src/converters/ncm/fix/jspwikiLinkLeftovers.d.ts +27 -0
  37. package/lib/src/converters/ncm/fix/moreInformationFooter.d.ts +25 -0
  38. package/lib/src/converters/ncm/fix/styleClosers.d.ts +25 -0
  39. package/lib/src/converters/ncm/fix/tightenLists.d.ts +30 -0
  40. package/lib/src/converters/ncm/fix/types.d.ts +33 -0
  41. package/lib/src/converters/ncm/footnotes.d.ts +39 -0
  42. package/lib/src/converters/ncm/images.d.ts +49 -0
  43. package/lib/src/converters/ncm/index.d.ts +20 -0
  44. package/lib/src/converters/ncm/links.d.ts +21 -0
  45. package/lib/src/converters/ncm/normalize.d.ts +39 -0
  46. package/lib/src/converters/ncm/placeholder.d.ts +25 -0
  47. package/lib/src/converters/ncm/tables.d.ts +36 -0
  48. package/lib/src/converters/ncm/types.d.ts +42 -0
  49. package/lib/src/http/egressPolicy.d.ts +52 -0
  50. package/lib/src/http/guardedFetch.d.ts +89 -0
  51. package/lib/src/http/guardedFetch.js +113 -0
  52. package/lib/src/http/guardedLookup.d.ts +48 -0
  53. package/lib/src/http/guardedLookup.js +73 -0
  54. package/lib/src/http/ssrf.d.ts +83 -0
  55. package/lib/src/http/ssrf.js +311 -0
  56. package/lib/src/mail/MailProvider.d.ts +36 -0
  57. package/lib/src/mail/NodemailerMailProvider.d.ts +26 -0
  58. package/lib/src/managers/AddonsManager.d.ts +691 -0
  59. package/lib/src/managers/AgentTokenManager.d.ts +269 -0
  60. package/lib/src/managers/AssetManager.d.ts +116 -0
  61. package/lib/src/managers/AttachmentManager.d.ts +1032 -0
  62. package/lib/src/managers/AuthManager.d.ts +1257 -0
  63. package/lib/src/managers/BackgroundJobManager.d.ts +434 -0
  64. package/lib/src/managers/BaseManager.d.ts +391 -0
  65. package/lib/src/managers/BaseManager.js +415 -0
  66. package/lib/src/managers/CacheManager.d.ts +159 -0
  67. package/lib/src/managers/CatalogManager.d.ts +235 -0
  68. package/lib/src/managers/CommentManager.d.ts +75 -0
  69. package/lib/src/managers/ConfigurationManager.d.ts +747 -0
  70. package/lib/src/managers/DatabaseManager.d.ts +89 -0
  71. package/lib/src/managers/DatabaseManager.js +236 -0
  72. package/lib/src/managers/EmailManager.d.ts +47 -0
  73. package/lib/src/managers/ExportManager.d.ts +129 -0
  74. package/lib/src/managers/ExportManager.js +420 -0
  75. package/lib/src/managers/FootnoteManager.d.ts +118 -0
  76. package/lib/src/managers/MediaManager.d.ts +279 -0
  77. package/lib/src/managers/MetricsManager.d.ts +89 -0
  78. package/lib/src/managers/NotificationManager.d.ts +165 -0
  79. package/lib/src/managers/OidcManager.d.ts +210 -0
  80. package/lib/src/managers/OrganizationManager.d.ts +107 -0
  81. package/lib/src/managers/PageManager.d.ts +1645 -0
  82. package/lib/src/managers/PersonManager.d.ts +48 -0
  83. package/lib/src/managers/PluginManager.d.ts +179 -0
  84. package/lib/src/managers/PluginManager.js +393 -0
  85. package/lib/src/managers/PolicyEvaluator.d.ts +213 -0
  86. package/lib/src/managers/RenderingManager.d.ts +358 -0
  87. package/lib/src/managers/RoleManager.d.ts +134 -0
  88. package/lib/src/managers/SearchManager.d.ts +402 -0
  89. package/lib/src/managers/ShareManager.d.ts +251 -0
  90. package/lib/src/managers/TemplateManager.d.ts +176 -0
  91. package/lib/src/managers/ThemeManager.d.ts +56 -0
  92. package/lib/src/managers/UserManager.d.ts +480 -0
  93. package/lib/src/managers/ValidationManager.d.ts +426 -0
  94. package/lib/src/managers/VariableManager.d.ts +145 -0
  95. package/lib/src/middleware/csrf.d.ts +3 -0
  96. package/lib/src/parsers/LinkParser.d.ts +457 -0
  97. package/lib/src/parsers/MarkupParser.d.ts +769 -0
  98. package/lib/src/parsers/context/ParseContext.d.ts +314 -0
  99. package/lib/src/parsers/data/emoji-map.d.ts +18 -0
  100. package/lib/src/parsers/dom/DOMBuilder.d.ts +210 -0
  101. package/lib/src/parsers/dom/DOMParser.d.ts +252 -0
  102. package/lib/src/parsers/dom/Tokenizer.d.ts +358 -0
  103. package/lib/src/parsers/dom/WikiDocument.d.ts +272 -0
  104. package/lib/src/parsers/dom/handlers/DOMLinkHandler.d.ts +260 -0
  105. package/lib/src/parsers/dom/handlers/DOMPluginHandler.d.ts +212 -0
  106. package/lib/src/parsers/dom/handlers/DOMVariableHandler.d.ts +171 -0
  107. package/lib/src/parsers/filters/BaseFilter.d.ts +269 -0
  108. package/lib/src/parsers/filters/FilterChain.d.ts +320 -0
  109. package/lib/src/parsers/handlers/BaseSyntaxHandler.d.ts +331 -0
  110. package/lib/src/parsers/handlers/HandlerRegistry.d.ts +225 -0
  111. package/lib/src/parsers/handlers/JSPWikiPreprocessor.d.ts +89 -0
  112. package/lib/src/parsers/handlers/LinkParserHandler.d.ts +80 -0
  113. package/lib/src/parsers/handlers/PluginSyntaxHandler.d.ts +78 -0
  114. package/lib/src/parsers/handlers/WikiFormHandler.d.ts +149 -0
  115. package/lib/src/parsers/handlers/WikiTagHandler.d.ts +125 -0
  116. package/lib/src/parsers/jspwikiTableRow.d.ts +39 -0
  117. package/lib/src/parsers/styleBlockSyntax.d.ts +14 -0
  118. package/lib/src/plugins/types.d.ts +79 -0
  119. package/lib/src/providers/AgentTokenAuthProvider.d.ts +40 -0
  120. package/lib/src/providers/AuthentikBearerAuthProvider.d.ts +75 -0
  121. package/lib/src/providers/BaseAttachmentProvider.d.ts +197 -0
  122. package/lib/src/providers/BaseAuthProvider.d.ts +220 -0
  123. package/lib/src/providers/BaseCacheProvider.d.ts +140 -0
  124. package/lib/src/providers/BaseCredentialsProvider.d.ts +84 -0
  125. package/lib/src/providers/BaseDatabaseProvider.d.ts +22 -0
  126. package/lib/src/providers/BaseDatabaseProvider.js +12 -0
  127. package/lib/src/providers/BaseLoggingProvider.d.ts +68 -0
  128. package/lib/src/providers/BaseLoggingProvider.js +37 -0
  129. package/lib/src/providers/BaseMediaProvider.d.ts +308 -0
  130. package/lib/src/providers/BasePageProvider.d.ts +337 -0
  131. package/lib/src/providers/BaseProvider.d.ts +98 -0
  132. package/lib/src/providers/BaseProvider.js +74 -0
  133. package/lib/src/providers/BaseSearchProvider.d.ts +362 -0
  134. package/lib/src/providers/BasicAttachmentProvider.d.ts +599 -0
  135. package/lib/src/providers/CloudflareAccessAuthProvider.d.ts +72 -0
  136. package/lib/src/providers/FileCredentialsProvider.d.ts +33 -0
  137. package/lib/src/providers/FileJobStateProvider.d.ts +58 -0
  138. package/lib/src/providers/FileLoggingProvider.d.ts +43 -0
  139. package/lib/src/providers/FileLoggingProvider.js +120 -0
  140. package/lib/src/providers/FileOidcAdapter.d.ts +25 -0
  141. package/lib/src/providers/FileSystemMediaProvider.d.ts +403 -0
  142. package/lib/src/providers/LunrSearchProvider.d.ts +280 -0
  143. package/lib/src/providers/MagicLinkAuthProvider.d.ts +125 -0
  144. package/lib/src/providers/NullCacheProvider.d.ts +72 -0
  145. package/lib/src/providers/OidcAuthProvider.d.ts +180 -0
  146. package/lib/src/providers/OidcBearerAuthProvider.d.ts +30 -0
  147. package/lib/src/providers/PasskeyAuthProvider.d.ts +58 -0
  148. package/lib/src/providers/PasswordAuthProvider.d.ts +20 -0
  149. package/lib/src/providers/SqliteDatabaseProvider.d.ts +55 -0
  150. package/lib/src/providers/SqliteDatabaseProvider.js +90 -0
  151. package/lib/src/providers/SqliteJobStateProvider.d.ts +41 -0
  152. package/lib/src/providers/databaseMigrations.d.ts +8 -0
  153. package/lib/src/providers/databaseMigrations.js +47 -0
  154. package/lib/src/providers/jobState.d.ts +99 -0
  155. package/lib/src/providers/lunrQueryToMiniSearch.d.ts +32 -0
  156. package/lib/src/providers/mediaFilenameDate.d.ts +22 -0
  157. package/lib/src/providers/sqliteLocation.d.ts +17 -0
  158. package/lib/src/providers/sqliteLocation.js +55 -0
  159. package/lib/src/providers/sqliteMigrations.d.ts +48 -0
  160. package/lib/src/providers/sqliteMigrations.js +72 -0
  161. package/lib/src/rendering/githubAlerts.d.ts +22 -0
  162. package/lib/src/rendering/htmlPolicy.d.ts +76 -0
  163. package/lib/src/rendering/markdownConverter.d.ts +73 -0
  164. package/lib/src/security/PolicyDecisionPoint.d.ts +245 -0
  165. package/lib/src/security/PolicyInformationPoint.d.ts +448 -0
  166. package/lib/src/security/pageActions.d.ts +40 -0
  167. package/lib/src/security/permissions.generated.d.ts +78 -0
  168. package/lib/src/types/Asset.d.ts +414 -0
  169. package/lib/src/types/Catalog.d.ts +61 -0
  170. package/lib/src/types/Comment.d.ts +11 -0
  171. package/lib/src/types/Config.d.ts +388 -0
  172. package/lib/src/types/Organization.d.ts +78 -0
  173. package/lib/src/types/OrganizationProvider.d.ts +38 -0
  174. package/lib/src/types/Page.d.ts +220 -0
  175. package/lib/src/types/Person.d.ts +56 -0
  176. package/lib/src/types/PersonProvider.d.ts +25 -0
  177. package/lib/src/types/Policy.d.ts +139 -0
  178. package/lib/src/types/Policy.js +17 -0
  179. package/lib/src/types/Provider.d.ts +810 -0
  180. package/lib/src/types/Role.d.ts +84 -0
  181. package/lib/src/types/RoleProvider.d.ts +35 -0
  182. package/lib/src/types/Schema.d.ts +425 -0
  183. package/lib/src/types/Share.d.ts +172 -0
  184. package/lib/src/types/Share.js +81 -0
  185. package/lib/src/types/User.d.ts +304 -0
  186. package/lib/src/types/Version.d.ts +189 -0
  187. package/lib/src/types/WikiEngine.d.ts +128 -0
  188. package/lib/src/types/express.d.ts +120 -0
  189. package/lib/src/types/express.js +1 -0
  190. package/lib/src/types/guards.d.ts +78 -0
  191. package/lib/src/types/heic-decode.d.ts +16 -0
  192. package/lib/src/types/index.d.ts +19 -0
  193. package/lib/src/types/linkedom.d.ts +77 -0
  194. package/lib/src/types/markdown-it-plugins.d.ts +21 -0
  195. package/lib/src/utils/DeltaStorage.d.ts +118 -0
  196. package/lib/src/utils/LocaleUtils.d.ts +105 -0
  197. package/lib/src/utils/LocaleUtils.js +294 -0
  198. package/lib/src/utils/PageNameMatcher.d.ts +98 -0
  199. package/lib/src/utils/PathPreflight.d.ts +38 -0
  200. package/lib/src/utils/PathPreflight.js +50 -0
  201. package/lib/src/utils/SectionUtils.d.ts +40 -0
  202. package/lib/src/utils/addonConfigLayer.d.ts +144 -0
  203. package/lib/src/utils/addonConfigLayer.js +382 -0
  204. package/lib/src/utils/addonDatabaseRules.d.ts +25 -0
  205. package/lib/src/utils/addonDatabaseRules.js +57 -0
  206. package/lib/src/utils/addonHost.d.ts +51 -0
  207. package/lib/src/utils/addonHost.js +72 -0
  208. package/lib/src/utils/addonInstaller.d.ts +109 -0
  209. package/lib/src/utils/addonLock.d.ts +37 -0
  210. package/lib/src/utils/addonLock.js +48 -0
  211. package/lib/src/utils/addonPageSync.d.ts +45 -0
  212. package/lib/src/utils/addonProvenance.d.ts +48 -0
  213. package/lib/src/utils/addonTarball.d.ts +41 -0
  214. package/lib/src/utils/addonsPathResolver.d.ts +76 -0
  215. package/lib/src/utils/addonsPathResolver.js +129 -0
  216. package/lib/src/utils/assetMetadataTags.d.ts +66 -0
  217. package/lib/src/utils/atomicWrite.d.ts +28 -0
  218. package/lib/src/utils/auditConfigChange.d.ts +57 -0
  219. package/lib/src/utils/auditEventNames.d.ts +50 -0
  220. package/lib/src/utils/auditEventNames.generated.d.ts +163 -0
  221. package/lib/src/utils/auditEventNames.generated.js +168 -0
  222. package/lib/src/utils/auditEventNames.js +49 -0
  223. package/lib/src/utils/auditEvents.d.ts +265 -0
  224. package/lib/src/utils/auditEvents.js +309 -0
  225. package/lib/src/utils/auditRegistry.d.ts +102 -0
  226. package/lib/src/utils/auditRegistry.js +168 -0
  227. package/lib/src/utils/bip39English.d.ts +2 -0
  228. package/lib/src/utils/configEnvKeys.d.ts +68 -0
  229. package/lib/src/utils/configFiles.d.ts +74 -0
  230. package/lib/src/utils/configFiles.js +150 -0
  231. package/lib/src/utils/delegation.d.ts +48 -0
  232. package/lib/src/utils/deriveUsername.d.ts +21 -0
  233. package/lib/src/utils/explainMediaPath.d.ts +123 -0
  234. package/lib/src/utils/formerTitles.d.ts +69 -0
  235. package/lib/src/utils/frontmatterAccess.d.ts +54 -0
  236. package/lib/src/utils/headlessAdminPassword.d.ts +55 -0
  237. package/lib/src/utils/imageTransform.d.ts +52 -0
  238. package/lib/src/utils/instanceEnvSecret.d.ts +51 -0
  239. package/lib/src/utils/jobSlots.d.ts +43 -0
  240. package/lib/src/utils/keywordNormalizer.d.ts +127 -0
  241. package/lib/src/utils/logger.d.ts +70 -0
  242. package/lib/src/utils/logger.js +106 -0
  243. package/lib/src/utils/magicLinkDeviceState.d.ts +95 -0
  244. package/lib/src/utils/managerUtils.d.ts +40 -0
  245. package/lib/src/utils/migrateLegacyPrivateAttachments.d.ts +15 -0
  246. package/lib/src/utils/orgFilename.d.ts +22 -0
  247. package/lib/src/utils/pageFeature.d.ts +27 -0
  248. package/lib/src/utils/pageFrontmatter.d.ts +21 -0
  249. package/lib/src/utils/pageNotes.d.ts +150 -0
  250. package/lib/src/utils/pageTitleRule.d.ts +37 -0
  251. package/lib/src/utils/pageToArticle.d.ts +59 -0
  252. package/lib/src/utils/pageUrl.d.ts +18 -0
  253. package/lib/src/utils/passwordChange.d.ts +12 -0
  254. package/lib/src/utils/passwordHash.d.ts +60 -0
  255. package/lib/src/utils/permissionCatalog.d.ts +52 -0
  256. package/lib/src/utils/pluginFormatters.d.ts +452 -0
  257. package/lib/src/utils/pluginFormatters.js +714 -0
  258. package/lib/src/utils/privateLinkRewrite.d.ts +42 -0
  259. package/lib/src/utils/privateStoreAccess.d.ts +57 -0
  260. package/lib/src/utils/privateStoreAccess.js +65 -0
  261. package/lib/src/utils/privateStoreCatalogs.d.ts +40 -0
  262. package/lib/src/utils/privateStoreCrypto.d.ts +127 -0
  263. package/lib/src/utils/privateStoreDoor.d.ts +181 -0
  264. package/lib/src/utils/privateStoreExport.d.ts +72 -0
  265. package/lib/src/utils/privateStoreFiles.d.ts +120 -0
  266. package/lib/src/utils/privateStoreMeta.d.ts +64 -0
  267. package/lib/src/utils/privateStoreMigrations.d.ts +37 -0
  268. package/lib/src/utils/privateStorePath.d.ts +259 -0
  269. package/lib/src/utils/privateStorePath.js +436 -0
  270. package/lib/src/utils/privateStoreTakeout.d.ts +76 -0
  271. package/lib/src/utils/privateStoreUnlock.d.ts +175 -0
  272. package/lib/src/utils/proxyClaim.d.ts +59 -0
  273. package/lib/src/utils/proxyClaim.js +30 -0
  274. package/lib/src/utils/redactPrivateNames.d.ts +21 -0
  275. package/lib/src/utils/redactPrivateNames.js +89 -0
  276. package/lib/src/utils/redactSecrets.d.ts +58 -0
  277. package/lib/src/utils/redactSecrets.js +171 -0
  278. package/lib/src/utils/renameLinkRewrite.d.ts +112 -0
  279. package/lib/src/utils/roleGrants.d.ts +35 -0
  280. package/lib/src/utils/safeRedirect.d.ts +32 -0
  281. package/lib/src/utils/schedule.d.ts +54 -0
  282. package/lib/src/utils/secretFileMode.d.ts +17 -0
  283. package/lib/src/utils/seededShippedPages.d.ts +45 -0
  284. package/lib/src/utils/sensitiveValues.d.ts +59 -0
  285. package/lib/src/utils/sensitiveValues.js +106 -0
  286. package/lib/src/utils/sessionEnd.d.ts +12 -0
  287. package/lib/src/utils/sessionGeneration.d.ts +24 -0
  288. package/lib/src/utils/sessionIdle.d.ts +41 -0
  289. package/lib/src/utils/sessionLimits.d.ts +68 -0
  290. package/lib/src/utils/shareLockbox.d.ts +64 -0
  291. package/lib/src/utils/sigstoreTrustedRoot.d.ts +33 -0
  292. package/lib/src/utils/sniffMime.d.ts +21 -0
  293. package/lib/src/utils/storeSearchIndex.d.ts +75 -0
  294. package/lib/src/utils/subjectMayDo.d.ts +21 -0
  295. package/lib/src/utils/systemCategoryRules.d.ts +30 -0
  296. package/lib/src/utils/systemCategoryRules.js +41 -0
  297. package/lib/src/utils/userCreateError.d.ts +62 -0
  298. package/lib/src/utils/username.d.ts +53 -0
  299. package/lib/src/utils/vaultUrlRewrite.d.ts +27 -0
  300. package/lib/src/utils/version.d.ts +84 -0
  301. package/lib/src/utils/videoFrame.d.ts +21 -0
  302. package/package.json +54 -3
@@ -0,0 +1,714 @@
1
+ /**
2
+ * Shared plugin formatting utilities for ngdpbase plugins.
3
+ * Implements the common output formats defined in issue #238 (Code Consolidation).
4
+ *
5
+ * All plugins should use these helpers to ensure consistent behaviour for
6
+ * `max`, `format`, `before`, and `after` parameters across the platform.
7
+ *
8
+ * Owned by PluginManager (#1820). Plugins use `context.formatters`; other
9
+ * callers use `engine.getManager('PluginManager').formatters`. Direct imports
10
+ * of this module are being retired; types stay importable with `import type`.
11
+ */
12
+ /**
13
+ * Parse a `max` plugin parameter to an integer.
14
+ * Returns `defaultMax` if the value is missing, empty, or non-numeric.
15
+ * A value of 0 means unlimited.
16
+ */
17
+ export function parseMaxParam(value, defaultMax = 0) {
18
+ if (value === undefined || value === null || value === '')
19
+ return defaultMax;
20
+ const n = parseInt(String(value), 10);
21
+ return isNaN(n) || n < 0 ? defaultMax : n;
22
+ }
23
+ /**
24
+ * Apply a max limit to an array.
25
+ * max=0 means unlimited (returns all items unchanged).
26
+ */
27
+ export function applyMax(items, max) {
28
+ return max > 0 ? items.slice(0, max) : items;
29
+ }
30
+ /**
31
+ * Escape HTML special characters.
32
+ * Accepts any primitive value; null/undefined return an empty string.
33
+ */
34
+ export function escapeHtml(text) {
35
+ if (text === null || text === undefined)
36
+ return '';
37
+ const map = {
38
+ '&': '&amp;',
39
+ '<': '&lt;',
40
+ '>': '&gt;',
41
+ '"': '&quot;',
42
+ "'": '&#039;'
43
+ };
44
+ return String(text).replace(/[&<>"']/g, m => map[m]);
45
+ }
46
+ /**
47
+ * Format a list of page links as an HTML bullet list.
48
+ * Supports optional `before`/`after` markers around each item (JSPWiki-style).
49
+ * Returns an informational message when the list is empty.
50
+ */
51
+ export function formatAsList(links, options = {}) {
52
+ if (links.length === 0) {
53
+ return '<p><em>No pages found.</em></p>';
54
+ }
55
+ const before = options.before ?? '';
56
+ const after = options.after ?? '';
57
+ const processedBefore = before.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
58
+ const processedAfter = after.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
59
+ const buildAnchor = (link) => {
60
+ const cls = link.cssClass ? ` class="${link.cssClass}"` : '';
61
+ const style = link.style ? ` style="${link.style}"` : '';
62
+ const title = link.title ? ` title="${escapeHtml(link.title)}"` : '';
63
+ const icon = link.icon ? `<i class="${escapeHtml(link.icon)}"></i> ` : '';
64
+ return `<a href="${link.href}"${cls}${style}${title}>${icon}${escapeHtml(link.text)}</a>`;
65
+ };
66
+ // #1306: a row is the anchor plus whatever rides along with it. A link with
67
+ // neither a badge nor an action renders exactly the markup it always did —
68
+ // the eight plugins already calling this must not change.
69
+ const buildItem = (link) => {
70
+ const anchor = buildAnchor(link);
71
+ const badge = link.badge !== undefined
72
+ ? `<span class="badge bg-secondary">${escapeHtml(link.badge)}</span>`
73
+ : '';
74
+ const trailing = link.trailingHtml ?? '';
75
+ if (!badge && !trailing)
76
+ return anchor;
77
+ return `<span class="d-flex align-items-center justify-content-between w-100">${anchor}${badge}${trailing}</span>`;
78
+ };
79
+ const itemAttr = options.itemClass ? ` class="${options.itemClass}"` : '';
80
+ const listAttr = options.listClass ? ` class="${options.listClass}"` : '';
81
+ const renderItems = () => links.map(l => `<li${itemAttr}>${buildItem(l)}</li>`).join('\n');
82
+ if (processedBefore || processedAfter) {
83
+ const isList = processedBefore.includes('*') || processedBefore.includes('-');
84
+ if (isList) {
85
+ return `<ul${listAttr}>\n${renderItems()}\n</ul>`;
86
+ }
87
+ const safeBefore = processedBefore.replace(/\*/g, '&#42; ');
88
+ const safeAfter = processedAfter.replace(/\*/g, '&#42; ');
89
+ return links
90
+ .map(l => `${safeBefore}${buildAnchor(l)}${safeAfter}`)
91
+ .join('\n')
92
+ .replace(/\n/g, '<br>');
93
+ }
94
+ // Default: <ul><li> list
95
+ return `<ul${listAttr}>\n${renderItems()}\n</ul>`;
96
+ }
97
+ /**
98
+ * Format a count with locale-appropriate thousands separators (e.g. 32,227).
99
+ */
100
+ export function formatAsCount(n) {
101
+ return n.toLocaleString('en-US');
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Current-user token resolution
105
+ // ---------------------------------------------------------------------------
106
+ /** Usernames that represent an unauthenticated visitor */
107
+ const ANONYMOUS_NAMES = new Set(['anonymous', '']);
108
+ /**
109
+ * Resolve a plugin parameter that may contain the special token `$currentUser`
110
+ * (case-insensitive) to the authenticated user's username.
111
+ *
112
+ * Rules:
113
+ * - Non-token values are returned unchanged.
114
+ * - `$currentUser` resolves to the username from `context.userContext.username`
115
+ * or `context.userName`, whichever is set first.
116
+ * - Returns `undefined` when the token was used but the visitor is anonymous
117
+ * (username is "anonymous" or absent). The caller can use this
118
+ * to detect the "token present but not logged in" case and show a prompt.
119
+ *
120
+ * @example
121
+ * // In a plugin execute():
122
+ * const author = resolveUserParam(params.author, context);
123
+ * if (params.author?.toLowerCase() === '$currentuser' && !author) {
124
+ * return '<p>Please log in.</p>';
125
+ * }
126
+ */
127
+ export function resolveUserParam(value, context) {
128
+ if (!value)
129
+ return value;
130
+ if (String(value).toLowerCase() !== '$currentuser')
131
+ return value;
132
+ const username = (context.userContext?.username)
133
+ || (context.userName)
134
+ || '';
135
+ return ANONYMOUS_NAMES.has(username.toLowerCase()) ? undefined : username;
136
+ }
137
+ // ---------------------------------------------------------------------------
138
+ // 'current' keyword resolution (page-scoped plugins)
139
+ // ---------------------------------------------------------------------------
140
+ /**
141
+ * Resolve a keyword parameter that may be the special token `current`
142
+ * (case-insensitive) to the page's own keyword.
143
+ *
144
+ * EXIF/media and page keywords follow no single name-vs-slug convention across
145
+ * a library — some content is tagged with the page NAME (`Dining`, `Travel`),
146
+ * some with the SLUG (`2026-trip-west`). `current` therefore adapts: it tries
147
+ * both the page name and its slug and keeps whichever yields more results
148
+ * (tie / both-empty → name form). This gives page-scoped plugins one
149
+ * self-scoping syntax that works regardless of how content was tagged:
150
+ * [{MediaPlugin keyword='current'}] [{Search user-keywords='current'}]
151
+ *
152
+ * Non-`current` values are looked up as-is (single lookup).
153
+ *
154
+ * @param value Raw keyword param — `current` (any case) triggers resolution.
155
+ * @param pageName Current page name (from plugin context).
156
+ * @param slugify Name→slug; pass `ValidationManager.generateSlug` for the
157
+ * canonical algorithm, or a simple slugifier as fallback.
158
+ * @param lookup Fetch results for a keyword (e.g. `listByKeyword`, or an
159
+ * `advancedSearch`-by-keyword closure).
160
+ * @returns The chosen keyword string and its results (results reusable by the
161
+ * caller so `current` costs at most two lookups, not three).
162
+ */
163
+ export async function resolveCurrentKeyword(value, pageName, slugify, lookup) {
164
+ if (value.toLowerCase() !== 'current') {
165
+ return { keyword: value, results: value ? await lookup(value) : [] };
166
+ }
167
+ const name = pageName ?? '';
168
+ const slug = name ? slugify(name) : '';
169
+ const [byName, bySlug] = await Promise.all([
170
+ name ? lookup(name) : Promise.resolve([]),
171
+ slug && slug !== name ? lookup(slug) : Promise.resolve([])
172
+ ]);
173
+ return bySlug.length > byName.length
174
+ ? { keyword: slug, results: bySlug }
175
+ : { keyword: name, results: byName };
176
+ }
177
+ /**
178
+ * Simple ASCII slugify fallback for callers without ValidationManager access.
179
+ * Matches `generateSlug` for the common ASCII case (spaces/punct → single
180
+ * hyphens, lowercased). Non-ASCII transliteration is NOT applied — pass
181
+ * `ValidationManager.generateSlug` when that matters.
182
+ */
183
+ export function simpleSlug(name) {
184
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
185
+ }
186
+ /**
187
+ * Parse a `sort=` plugin parameter.
188
+ * Accepts "name", "name-asc", "name-desc", "count-asc", "count-desc", etc.
189
+ * validKeys is the list of allowed key names.
190
+ * Falls back to defaultKey/defaultOrder for any unrecognised value.
191
+ */
192
+ export function parseSortParam(value, validKeys, defaultKey, defaultOrder = 'asc') {
193
+ if (!value)
194
+ return { key: defaultKey, order: defaultOrder };
195
+ const v = String(value).toLowerCase().trim();
196
+ for (const key of validKeys) {
197
+ if (v === key)
198
+ return { key, order: defaultOrder };
199
+ if (v === `${key}-asc`)
200
+ return { key, order: 'asc' };
201
+ if (v === `${key}-desc`)
202
+ return { key, order: 'desc' };
203
+ }
204
+ return { key: defaultKey, order: defaultOrder };
205
+ }
206
+ /**
207
+ * Format rows of data as an HTML table.
208
+ * Cell values may contain raw HTML (e.g. anchor tags).
209
+ * Header values are plain text and will be HTML-escaped.
210
+ *
211
+ * @param headers - Column header strings (plain text)
212
+ * @param rows - Array of row arrays; each element is a cell value (may be raw HTML)
213
+ * @param options - Optional table rendering options (sortable, pagination hints, etc.)
214
+ */
215
+ export function formatAsTable(headers, rows, options) {
216
+ const opts = options ?? {};
217
+ if (rows.length === 0) {
218
+ return `<p><em>${escapeHtml(opts.emptyMessage ?? 'No pages found.')}</em></p>`;
219
+ }
220
+ const classes = ['plugin-table'];
221
+ if (opts.sortable)
222
+ classes.push('sortable');
223
+ let tableAttrs = `class="${classes.join(' ')}"`;
224
+ if (opts.sortable && opts.defaultSortColumn !== undefined) {
225
+ tableAttrs += ` data-sort-column="${opts.defaultSortColumn}" data-sort-direction="${opts.defaultSortOrder ?? 'asc'}"`;
226
+ }
227
+ const headerHtml = headers.map(h => `<th>${escapeHtml(h)}</th>`).join('');
228
+ const rowsHtml = rows.map((row, rowIndex) => `<tr>${row.map((cell, colIdx) => {
229
+ if (opts.cellDataSort?.[colIdx]) {
230
+ const sortVal = opts.cellDataSort[colIdx](row, rowIndex);
231
+ return `<td data-sort="${escapeHtml(sortVal)}">${cell}</td>`;
232
+ }
233
+ return `<td>${cell}</td>`;
234
+ }).join('')}</tr>`).join('\n');
235
+ return [
236
+ `<table ${tableAttrs}>`,
237
+ `<thead><tr>${headerHtml}</tr></thead>`,
238
+ '<tbody>',
239
+ rowsHtml,
240
+ '</tbody>',
241
+ ...(opts.footer ? [`<tfoot><tr>${opts.footer.map(cell => `<td>${cell}</td>`).join('')}</tr></tfoot>`] : []),
242
+ '</table>'
243
+ ].join('\n');
244
+ }
245
+ // ---------------------------------------------------------------------------
246
+ // Money
247
+ // ---------------------------------------------------------------------------
248
+ /**
249
+ * Show an amount kept as whole minor units (cents) in its currency:
250
+ * `formatMoney(125050, 'USD')` is `$1,250.50`. The number of decimals is the
251
+ * currency's own (JPY 0, KWD 3); an unlisted code gets two. A malformed code
252
+ * never throws: it is shown after the number. Plain text: escape it when
253
+ * putting it in HTML.
254
+ */
255
+ export function formatMoney(minorUnits, currency, locale = 'en-US') {
256
+ try {
257
+ const format = new Intl.NumberFormat(locale, { style: 'currency', currency });
258
+ const digits = format.resolvedOptions().maximumFractionDigits ?? 2;
259
+ return format.format(minorUnits / 10 ** digits);
260
+ }
261
+ catch {
262
+ const number = new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(minorUnits / 100);
263
+ return `${number} ${currency}`;
264
+ }
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // Duration and date formatting
268
+ // ---------------------------------------------------------------------------
269
+ /**
270
+ * Format a duration in seconds as a human-readable string.
271
+ * Examples: "3d 12h 45m", "5h 30m", "15m"
272
+ */
273
+ export function formatDuration(seconds) {
274
+ const days = Math.floor(seconds / 86400);
275
+ const hours = Math.floor((seconds % 86400) / 3600);
276
+ const minutes = Math.floor((seconds % 3600) / 60);
277
+ if (days > 0)
278
+ return `${days}d ${hours}h ${minutes}m`;
279
+ if (hours > 0)
280
+ return `${hours}h ${minutes}m`;
281
+ return `${minutes}m`;
282
+ }
283
+ /**
284
+ * Format a Date as a locale-aware date/time string.
285
+ * Example: "Apr 19, 2026, 10:30 AM"
286
+ */
287
+ export function formatDateTime(date, locale = 'en-US') {
288
+ const options = {
289
+ year: 'numeric', month: 'short', day: 'numeric',
290
+ hour: 'numeric', minute: '2-digit', hour12: true
291
+ };
292
+ return date.toLocaleString(locale, options);
293
+ }
294
+ /**
295
+ * Format a Date as a relative time string.
296
+ * Examples: "just now", "2 hours ago", "3 days ago", "Apr 5, 2026"
297
+ */
298
+ export function formatRelativeTime(date, locale = 'en-US') {
299
+ const diffMs = Date.now() - date.getTime();
300
+ const diffSeconds = Math.floor(diffMs / 1000);
301
+ const diffMinutes = Math.floor(diffSeconds / 60);
302
+ const diffHours = Math.floor(diffMinutes / 60);
303
+ const diffDays = Math.floor(diffHours / 24);
304
+ if (diffSeconds < 60)
305
+ return 'just now';
306
+ if (diffMinutes < 60)
307
+ return `${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''} ago`;
308
+ if (diffHours < 24)
309
+ return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
310
+ if (diffDays < 7)
311
+ return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`;
312
+ return date.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
313
+ }
314
+ // ---------------------------------------------------------------------------
315
+ // Plugin parameter helpers
316
+ // ---------------------------------------------------------------------------
317
+ /**
318
+ * Split a comma-separated plugin parameter into a trimmed, non-empty array.
319
+ * Example: splitParam('a, b, c') → ['a', 'b', 'c']
320
+ */
321
+ export function splitParam(value) {
322
+ if (!value)
323
+ return [];
324
+ return String(value).split(',').map(s => s.trim()).filter(Boolean);
325
+ }
326
+ /**
327
+ * Parse a boolean plugin parameter.
328
+ * '0' and 'false' (case-insensitive) → false; '1' and 'true' → true; else → defaultVal.
329
+ */
330
+ export function parseBoolParam(value, defaultVal) {
331
+ if (value === undefined || value === null || value === '')
332
+ return defaultVal;
333
+ const s = String(value).toLowerCase().trim();
334
+ if (s === 'false' || s === '0')
335
+ return false;
336
+ if (s === 'true' || s === '1')
337
+ return true;
338
+ return defaultVal;
339
+ }
340
+ const VALID_PLACEMENTS = new Set(['right', 'left', 'block', 'inline']);
341
+ /**
342
+ * Parse a `placement=` plugin parameter to a validated `Placement` value.
343
+ * Falls back to `defaultPlacement` for missing or unrecognised values.
344
+ */
345
+ export function parsePlacementParam(value, defaultPlacement = 'right') {
346
+ if (value === undefined || value === null || value === '')
347
+ return defaultPlacement;
348
+ const v = String(value).toLowerCase().trim();
349
+ return VALID_PLACEMENTS.has(v) ? v : defaultPlacement;
350
+ }
351
+ /**
352
+ * Return the CSS class name for a given placement value.
353
+ * Always returns `plugin-placement-<placement>`.
354
+ */
355
+ export function placementClass(placement) {
356
+ return `plugin-placement-${placement}`;
357
+ }
358
+ // ---------------------------------------------------------------------------
359
+ // Text / content helpers
360
+ // ---------------------------------------------------------------------------
361
+ /**
362
+ * Strip frontmatter, plugin syntax, and markdown decoration from raw page
363
+ * content and return plain text suitable for a card excerpt, truncated at
364
+ * the last word boundary before maxLen characters.
365
+ */
366
+ export function extractExcerpt(raw, maxLen) {
367
+ const text = raw
368
+ .replace(/^---[\s\S]*?---\n?/, '') // YAML frontmatter
369
+ .replace(/\[\{[^\]]*\}\]/g, '') // [{Plugin}] syntax
370
+ .replace(/!\[.*?\]\(.*?\)/g, '') // markdown images
371
+ .replace(/^#{1,6}\s+/gm, '') // markdown headings
372
+ .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1') // bold / italic
373
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // markdown links → label
374
+ .replace(/\[([^\]]+)\]/g, '$1') // wiki links → label
375
+ .replace(/`{1,3}[^`]*`{1,3}/g, '') // code spans / fences
376
+ .replace(/^\s*[-*+]\s+/gm, '') // list bullets
377
+ .replace(/\n{2,}/g, ' ') // paragraph breaks → space
378
+ .replace(/\n/g, ' ')
379
+ .trim();
380
+ if (text.length <= maxLen)
381
+ return text;
382
+ const cut = text.lastIndexOf(' ', maxLen);
383
+ return (cut > 0 ? text.slice(0, cut) : text.slice(0, maxLen)) + '…';
384
+ }
385
+ /**
386
+ * Fisher-Yates in-place shuffle. Returns the same array reference.
387
+ */
388
+ export function shuffleArray(arr) {
389
+ for (let i = arr.length - 1; i > 0; i--) {
390
+ const j = Math.floor(Math.random() * (i + 1));
391
+ [arr[i], arr[j]] = [arr[j], arr[i]];
392
+ }
393
+ return arr;
394
+ }
395
+ // ---------------------------------------------------------------------------
396
+ // Pagination utilities
397
+ // ---------------------------------------------------------------------------
398
+ /**
399
+ * Parse a `page=` plugin parameter to an integer (1-based).
400
+ * Returns `defaultPage` if the value is missing, empty, or non-numeric.
401
+ */
402
+ export function parsePageParam(value, defaultPage = 1) {
403
+ if (value === undefined || value === null || value === '')
404
+ return defaultPage;
405
+ const n = parseInt(String(value), 10);
406
+ return isNaN(n) || n < 1 ? defaultPage : n;
407
+ }
408
+ /**
409
+ * Parse a `pageSize=` plugin parameter to an integer.
410
+ * Returns `defaultSize` (0 = disabled / use max= behaviour) for missing/invalid values.
411
+ */
412
+ export function parsePageSizeParam(value, defaultSize = 0) {
413
+ if (value === undefined || value === null || value === '')
414
+ return defaultSize;
415
+ const n = parseInt(String(value), 10);
416
+ return isNaN(n) || n < 0 ? defaultSize : n;
417
+ }
418
+ /**
419
+ * Slice `items` to the requested page.
420
+ * When `pageSize` is 0 (disabled), returns all items as page 1 of 1.
421
+ */
422
+ export function applyPagination(items, page, pageSize) {
423
+ if (pageSize <= 0) {
424
+ return { items, totalPages: 1, currentPage: 1, totalItems: items.length };
425
+ }
426
+ const totalItems = items.length;
427
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
428
+ const currentPage = Math.min(Math.max(1, page), totalPages);
429
+ const start = (currentPage - 1) * pageSize;
430
+ return {
431
+ items: items.slice(start, start + pageSize),
432
+ totalPages,
433
+ currentPage,
434
+ totalItems
435
+ };
436
+ }
437
+ /**
438
+ * How many numbered pages the sliding window shows at once.
439
+ *
440
+ * Mirrors `WikiPagination.renderNav` in public/js/wiki-pagination.js on
441
+ * purpose: the two layers must produce the same control, so the window they
442
+ * slide has to be the same width. Changing one without the other is how the
443
+ * application ended up with pagination that looked different depending on
444
+ * whether the page was rendered on the server or in the browser (#1299).
445
+ */
446
+ const PAGINATION_WINDOW = 7;
447
+ /** Prev/next glyphs, matching the client-side control's Font Awesome chevrons. */
448
+ const PAGINATION_CHEVRON_LEFT = '<i class="fas fa-chevron-left"></i>';
449
+ const PAGINATION_CHEVRON_RIGHT = '<i class="fas fa-chevron-right"></i>';
450
+ /** One `<li>` of the pagination list. */
451
+ function paginationItem(html, href, ariaLabel, active = false) {
452
+ const label = ariaLabel ? ` aria-label="${ariaLabel}"` : '';
453
+ if (active) {
454
+ // aria-current is how a screen reader learns which page it is on. The old
455
+ // plugin control said "Page 2 of 5" in visible text; a numbered pager makes
456
+ // that redundant for sighted users but silent for everyone else without it.
457
+ return `<li class="page-item active"><span class="page-link" aria-current="page"${label}>${html}</span></li>`;
458
+ }
459
+ if (href === null) {
460
+ return `<li class="page-item disabled"><span class="page-link"${label}>${html}</span></li>`;
461
+ }
462
+ return `<li class="page-item"><a class="page-link" href="${escapeHtml(href)}"${label}>${html}</a></li>`;
463
+ }
464
+ /**
465
+ * Render the canonical pagination control (#1300).
466
+ *
467
+ * This is the one pagination markup in the application. `WikiPagination`
468
+ * produces the same shape in the browser and enhances any instance it finds —
469
+ * clicks, arrow keys and swipe — so a surface gets the full control by emitting
470
+ * this and nothing else.
471
+ *
472
+ * The wrapper carries `data-current-page`, `data-total-pages` and the prev/next
473
+ * URLs, which is what lets the enhancer work without being told anything by the
474
+ * page. Everything it needs is in the markup.
475
+ *
476
+ * Returns '' for a single page: a control that can only ever show "1" is noise.
477
+ *
478
+ * @param currentPage - The current page number (1-based)
479
+ * @param totalPages - Total number of pages
480
+ * @param hrefFor - Builds the URL for a given 1-based page number
481
+ * @param ariaLabel - Accessible name for the nav landmark
482
+ * @example
483
+ * formatPaginationNav(3, 12, (p) => `/view/Index?page=${p}`)
484
+ */
485
+ export function formatPaginationNav(currentPage, totalPages, hrefFor, ariaLabel = 'Pagination') {
486
+ if (totalPages <= 1)
487
+ return '';
488
+ const current = Math.min(Math.max(1, Math.floor(currentPage) || 1), totalPages);
489
+ const items = [];
490
+ items.push(current > 1
491
+ ? paginationItem(PAGINATION_CHEVRON_LEFT, hrefFor(current - 1), 'Previous')
492
+ : paginationItem(PAGINATION_CHEVRON_LEFT, null, 'Previous'));
493
+ const half = Math.floor(PAGINATION_WINDOW / 2);
494
+ let startPage = Math.max(1, current - half);
495
+ const endPage = Math.min(totalPages, startPage + PAGINATION_WINDOW - 1);
496
+ if (endPage - startPage < PAGINATION_WINDOW - 1) {
497
+ startPage = Math.max(1, endPage - PAGINATION_WINDOW + 1);
498
+ }
499
+ if (startPage > 1) {
500
+ items.push(paginationItem('1', hrefFor(1)));
501
+ // An ellipsis standing in for a single page hides nothing and costs a
502
+ // click to discover that, so only draw one when it covers a gap.
503
+ if (startPage > 2)
504
+ items.push(paginationItem('&hellip;', null));
505
+ }
506
+ for (let page = startPage; page <= endPage; page++) {
507
+ items.push(paginationItem(String(page), hrefFor(page), undefined, page === current));
508
+ }
509
+ if (endPage < totalPages) {
510
+ if (endPage < totalPages - 1)
511
+ items.push(paginationItem('&hellip;', null));
512
+ items.push(paginationItem(String(totalPages), hrefFor(totalPages)));
513
+ }
514
+ items.push(current < totalPages
515
+ ? paginationItem(PAGINATION_CHEVRON_RIGHT, hrefFor(current + 1), 'Next')
516
+ : paginationItem(PAGINATION_CHEVRON_RIGHT, null, 'Next'));
517
+ const prevAttr = current > 1 ? ` data-prev-url="${escapeHtml(hrefFor(current - 1))}"` : '';
518
+ const nextAttr = current < totalPages ? ` data-next-url="${escapeHtml(hrefFor(current + 1))}"` : '';
519
+ return `<nav class="wiki-pagination" aria-label="${escapeHtml(ariaLabel)}" data-pagination `
520
+ + `data-current-page="${current}" data-total-pages="${totalPages}"${prevAttr}${nextAttr}>`
521
+ + `<ul class="pagination pagination-sm mb-0">${items.join('')}</ul>`
522
+ + '</nav>';
523
+ }
524
+ // ---------------------------------------------------------------------------
525
+ // Summary-stat filter bar (#1303)
526
+ // ---------------------------------------------------------------------------
527
+ /** Bootstrap card tones the bar will emit. Anything else falls back to neutral. */
528
+ const STAT_TONES = ['primary', 'success', 'info', 'warning', 'danger', 'secondary'];
529
+ /** Card colour classes, kept in one place so a tone means one thing everywhere. */
530
+ function statToneClass(tone) {
531
+ if (!tone || !STAT_TONES.includes(tone))
532
+ return 'bg-light';
533
+ return tone === 'warning' || tone === 'info' ? `bg-${tone} text-dark` : `bg-${tone} text-white`;
534
+ }
535
+ /**
536
+ * Render the canonical summary-stat filter bar (#1303).
537
+ *
538
+ * `/admin/users` had four cards that filter its table when clicked. Nine other
539
+ * surfaces render summary counts in five presentations and none of them filter
540
+ * — `admin-keywords` pixel-identical to the one that works, `admin-audit`
541
+ * defining a `:hover` rule on cards with no click handler. An inconsistent
542
+ * appearance is untidy; an inconsistent affordance is a lie.
543
+ *
544
+ * The markup is the contract, as it is for `formatPaginationNav`: a card is
545
+ * clickable because of what it carries, and `WikiStatFilters.enhance()` wires
546
+ * any bar it finds. That is what lets a plugin an admin wrote offer the same
547
+ * interaction as a core admin page.
548
+ *
549
+ * Three kinds of card, and the difference is the whole design:
550
+ *
551
+ * - `href` — a link. The server filters, so it is correct on a paginated list.
552
+ * - `match` — a client-side row filter. Correct only when every row is loaded.
553
+ * - `clears` — the card that puts everything back.
554
+ * - none of them — a card that reports a number and does not pretend to filter.
555
+ *
556
+ * Returns '' for an empty list.
557
+ *
558
+ * @param stats - The cards, in display order
559
+ * @param options - Row selector, aria label, column class
560
+ * @example
561
+ * formatStatFilters(
562
+ * [{ label: 'Active', value: 9, match: 'status=active', tone: 'success' }],
563
+ * { rowSelector: 'tbody tr[data-username]' }
564
+ * )
565
+ */
566
+ export function formatStatFilters(stats, options = {}) {
567
+ if (!stats.length)
568
+ return '';
569
+ const { rowSelector, ariaLabel = 'Summary filters', columnClass = 'col-md-3' } = options;
570
+ const cards = stats.map((stat) => {
571
+ const body = '<div class="card-body">'
572
+ + `<h5 class="card-title">${escapeHtml(stat.label)}</h5>`
573
+ + `<h3 class="mb-0">${escapeHtml(String(stat.value))}</h3>`
574
+ + '</div>';
575
+ const classes = `card stat-filter ${statToneClass(stat.tone)}`;
576
+ const titleAttr = stat.title ? ` title="${escapeHtml(stat.title)}"` : '';
577
+ const activeAttr = stat.active ? ' data-stat-active' : '';
578
+ let card;
579
+ if (stat.href) {
580
+ card = `<a class="${classes} text-decoration-none"${titleAttr}${activeAttr}`
581
+ + ` href="${escapeHtml(stat.href)}">${body}</a>`;
582
+ }
583
+ else if (stat.match || stat.clears) {
584
+ // A div rather than a button: the card is a block of markup, and a
585
+ // button element restyled to look like one loses more than role and
586
+ // tabindex give back here.
587
+ const behaviour = stat.match
588
+ ? ` data-stat-match="${escapeHtml(stat.match)}"`
589
+ : ' data-stat-clear';
590
+ card = `<div class="${classes}" role="button" tabindex="0"${titleAttr}${activeAttr}`
591
+ + `${behaviour}>${body}</div>`;
592
+ }
593
+ else {
594
+ card = `<div class="${classes}"${titleAttr}>${body}</div>`;
595
+ }
596
+ return `<div class="${escapeHtml(columnClass)}">${card}</div>`;
597
+ });
598
+ const rowsAttr = rowSelector ? ` data-stat-rows="${escapeHtml(rowSelector)}"` : '';
599
+ return `<div class="row mb-4 wiki-stat-filters" role="group" aria-label="${escapeHtml(ariaLabel)}"`
600
+ + ` data-stat-filters${rowsAttr}>${cards.join('')}</div>`;
601
+ }
602
+ /**
603
+ * Build pagination HTML for a plugin result set, addressed by page name.
604
+ *
605
+ * A thin wrapper over `formatPaginationNav` (#1301): it turns a page name and a
606
+ * query parameter into the `/view/{page}?{param}=N` URLs this application uses,
607
+ * and leaves the markup to the canonical control.
608
+ *
609
+ * It previously emitted its own `.plugin-pagination` div — prev/next text links
610
+ * with no page numbers and no styling, since that class had no CSS anywhere in
611
+ * the repo. Its signature and its single-page behaviour are unchanged, so the
612
+ * plugins calling it did not have to change; what they render did.
613
+ *
614
+ * Returns '' when there is only one page (nothing to navigate).
615
+ *
616
+ * @param currentPage - The current page number (1-based)
617
+ * @param totalPages - Total number of pages
618
+ * @param pageName - Wiki page name used to build `/view/{pageName}?page=N`
619
+ * @param queryParam - Query string parameter name (default: 'page')
620
+ */
621
+ export function formatPaginationLinks(currentPage, totalPages, pageName, queryParam = 'page') {
622
+ const base = `/view/${encodeURIComponent(pageName)}`;
623
+ return formatPaginationNav(currentPage, totalPages, (page) => `${base}?${queryParam}=${page}`);
624
+ }
625
+ /** The one manager method page text may reach (#1556). */
626
+ export const FETCHABLE_METHOD = 'toMarqueeText';
627
+ /**
628
+ * Resolve the `fetch='ManagerName.toMarqueeText(k=v,...)'` plugin convention to text.
629
+ *
630
+ * Extracted from MarqueePlugin (#685 slice 2). Page text names the manager and
631
+ * the method, so this is reachable by anyone who can edit a page and runs on
632
+ * every render, for every viewer (#1556). Two rules make that safe:
633
+ *
634
+ * - __Only `toMarqueeText`.__ Any other method — an index rebuild, a scan,
635
+ * anything with a side effect — is refused, whatever the manager offers.
636
+ * `BaseManager.toMarqueeText()` is the convention, and the only method any
637
+ * shipped page or doc uses.
638
+ * - __The viewer's context goes with it__ (every call carries a context). The
639
+ * manager decides what that viewer may see; nothing runs as the server.
640
+ *
641
+ * The raw `{k: v}` args object is passed as the first argument (the manager
642
+ * owns its own option parsing via `managerUtils.ts`), the viewer second.
643
+ *
644
+ * #1751: the render depends on the manager's data, so the page cache
645
+ * re-renders once that manager bumps its topic (its own name).
646
+ */
647
+ export async function resolveManagerFetch(spec, context) {
648
+ const call = await callManagerMethod(spec, context, FETCHABLE_METHOD);
649
+ if (call.status !== 'ok')
650
+ return call;
651
+ return { status: 'ok', text: String(call.value) };
652
+ }
653
+ /** The one manager method a form's `optionsSource: "fetch:..."` may reach. */
654
+ export const FORM_OPTIONS_METHOD = 'toFormOptions';
655
+ /**
656
+ * Resolve `ManagerName.toFormOptions(k=v,...)` to the choices of a form
657
+ * dropdown, under the same two rules as resolveManagerFetch: only
658
+ * `toFormOptions` is reachable, and the viewer's context goes with the call,
659
+ * so the manager offers that viewer only what they may see.
660
+ *
661
+ * The manager may answer strings or `{ value, label }` objects; anything
662
+ * else is dropped, and every value comes back as a string.
663
+ */
664
+ export async function resolveManagerOptions(spec, context) {
665
+ const call = await callManagerMethod(spec, context, FORM_OPTIONS_METHOD);
666
+ if (call.status !== 'ok')
667
+ return call;
668
+ return { status: 'ok', options: toFormOptionList(call.value) };
669
+ }
670
+ function toFormOptionList(value) {
671
+ if (!Array.isArray(value))
672
+ return [];
673
+ const options = [];
674
+ for (const item of value) {
675
+ if (typeof item === 'string') {
676
+ options.push({ value: item, label: item });
677
+ }
678
+ else if (item && typeof item === 'object') {
679
+ const { value: v, label } = item;
680
+ if ((typeof v === 'string' || typeof v === 'number') && String(v) !== '') {
681
+ options.push({ value: String(v), label: typeof label === 'string' && label !== '' ? label : String(v) });
682
+ }
683
+ }
684
+ }
685
+ return options;
686
+ }
687
+ /**
688
+ * Parse `Manager.method(k=v,...)` and call it with the args and the viewer,
689
+ * but only when `method` is the one `allowed` method (#1556).
690
+ */
691
+ async function callManagerMethod(spec, context, allowed) {
692
+ if (!spec || !context.engine)
693
+ return { status: 'no-spec' };
694
+ const match = String(spec).trim().match(/^([A-Za-z0-9_]+)\.([A-Za-z0-9_]+)\(([^)]*)\)$/);
695
+ if (!match)
696
+ return { status: 'no-spec' };
697
+ const [, managerName, methodName, argsStr] = match;
698
+ if (methodName !== allowed)
699
+ return { status: 'refused' };
700
+ const fetchArgs = {};
701
+ if (argsStr) {
702
+ for (const pair of argsStr.split(',')) {
703
+ const eq = pair.indexOf('=');
704
+ if (eq > 0)
705
+ fetchArgs[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
706
+ }
707
+ }
708
+ const manager = context.engine.getManager(managerName);
709
+ const method = manager?.[allowed];
710
+ if (typeof method !== 'function')
711
+ return { status: 'not-found' };
712
+ const value = await method.call(manager, fetchArgs, context.userContext);
713
+ return { status: 'ok', value };
714
+ }