@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.
- package/LICENSE +201 -0
- package/README.md +16 -2
- package/lib/packages/ngdpbase-types/index.d.ts +32 -0
- package/lib/packages/ngdpbase-types/testing.d.ts +43 -0
- package/lib/packages/ngdpbase-types/testing.js +53 -0
- package/lib/src/cache/CacheDependencies.d.ts +50 -0
- package/lib/src/cache/CacheDependencies.js +50 -0
- package/lib/src/cache/ICacheAdapter.d.ts +86 -0
- package/lib/src/cache/RegionCache.d.ts +171 -0
- package/lib/src/context/ActorContext.d.ts +48 -0
- package/lib/src/context/ActorContext.js +33 -0
- package/lib/src/context/ApiContext.d.ts +194 -0
- package/lib/src/context/ApiContext.js +273 -0
- package/lib/src/context/BaseContext.d.ts +110 -0
- package/lib/src/context/BaseContext.js +143 -0
- package/lib/src/context/JobContext.d.ts +153 -0
- package/lib/src/context/JobContext.js +144 -0
- package/lib/src/context/WikiContext.d.ts +404 -0
- package/lib/src/context/bootActions.d.ts +64 -0
- package/lib/src/context/bootActions.js +111 -0
- package/lib/src/converters/HtmlConverter.d.ts +70 -0
- package/lib/src/converters/IContentConverter.d.ts +94 -0
- package/lib/src/converters/JSPWikiConverter.d.ts +117 -0
- package/lib/src/converters/conversionWarning.d.ts +15 -0
- package/lib/src/converters/ncm/bridge.d.ts +22 -0
- package/lib/src/converters/ncm/existing.d.ts +23 -0
- package/lib/src/converters/ncm/fix/blocks.d.ts +89 -0
- package/lib/src/converters/ncm/fix/bulletMarkers.d.ts +21 -0
- package/lib/src/converters/ncm/fix/index.d.ts +36 -0
- package/lib/src/converters/ncm/fix/jspwikiBullets.d.ts +20 -0
- package/lib/src/converters/ncm/fix/jspwikiCodeMarkers.d.ts +38 -0
- package/lib/src/converters/ncm/fix/jspwikiHeadings.d.ts +25 -0
- package/lib/src/converters/ncm/fix/jspwikiIndent.d.ts +24 -0
- package/lib/src/converters/ncm/fix/jspwikiInsert.d.ts +21 -0
- package/lib/src/converters/ncm/fix/jspwikiItalic.d.ts +29 -0
- package/lib/src/converters/ncm/fix/jspwikiLinkLeftovers.d.ts +27 -0
- package/lib/src/converters/ncm/fix/moreInformationFooter.d.ts +25 -0
- package/lib/src/converters/ncm/fix/styleClosers.d.ts +25 -0
- package/lib/src/converters/ncm/fix/tightenLists.d.ts +30 -0
- package/lib/src/converters/ncm/fix/types.d.ts +33 -0
- package/lib/src/converters/ncm/footnotes.d.ts +39 -0
- package/lib/src/converters/ncm/images.d.ts +49 -0
- package/lib/src/converters/ncm/index.d.ts +20 -0
- package/lib/src/converters/ncm/links.d.ts +21 -0
- package/lib/src/converters/ncm/normalize.d.ts +39 -0
- package/lib/src/converters/ncm/placeholder.d.ts +25 -0
- package/lib/src/converters/ncm/tables.d.ts +36 -0
- package/lib/src/converters/ncm/types.d.ts +42 -0
- package/lib/src/http/egressPolicy.d.ts +52 -0
- package/lib/src/http/guardedFetch.d.ts +89 -0
- package/lib/src/http/guardedFetch.js +113 -0
- package/lib/src/http/guardedLookup.d.ts +48 -0
- package/lib/src/http/guardedLookup.js +73 -0
- package/lib/src/http/ssrf.d.ts +83 -0
- package/lib/src/http/ssrf.js +311 -0
- package/lib/src/mail/MailProvider.d.ts +36 -0
- package/lib/src/mail/NodemailerMailProvider.d.ts +26 -0
- package/lib/src/managers/AddonsManager.d.ts +691 -0
- package/lib/src/managers/AgentTokenManager.d.ts +269 -0
- package/lib/src/managers/AssetManager.d.ts +116 -0
- package/lib/src/managers/AttachmentManager.d.ts +1032 -0
- package/lib/src/managers/AuthManager.d.ts +1257 -0
- package/lib/src/managers/BackgroundJobManager.d.ts +434 -0
- package/lib/src/managers/BaseManager.d.ts +391 -0
- package/lib/src/managers/BaseManager.js +415 -0
- package/lib/src/managers/CacheManager.d.ts +159 -0
- package/lib/src/managers/CatalogManager.d.ts +235 -0
- package/lib/src/managers/CommentManager.d.ts +75 -0
- package/lib/src/managers/ConfigurationManager.d.ts +747 -0
- package/lib/src/managers/DatabaseManager.d.ts +89 -0
- package/lib/src/managers/DatabaseManager.js +236 -0
- package/lib/src/managers/EmailManager.d.ts +47 -0
- package/lib/src/managers/ExportManager.d.ts +129 -0
- package/lib/src/managers/ExportManager.js +420 -0
- package/lib/src/managers/FootnoteManager.d.ts +118 -0
- package/lib/src/managers/MediaManager.d.ts +279 -0
- package/lib/src/managers/MetricsManager.d.ts +89 -0
- package/lib/src/managers/NotificationManager.d.ts +165 -0
- package/lib/src/managers/OidcManager.d.ts +210 -0
- package/lib/src/managers/OrganizationManager.d.ts +107 -0
- package/lib/src/managers/PageManager.d.ts +1645 -0
- package/lib/src/managers/PersonManager.d.ts +48 -0
- package/lib/src/managers/PluginManager.d.ts +179 -0
- package/lib/src/managers/PluginManager.js +393 -0
- package/lib/src/managers/PolicyEvaluator.d.ts +213 -0
- package/lib/src/managers/RenderingManager.d.ts +358 -0
- package/lib/src/managers/RoleManager.d.ts +134 -0
- package/lib/src/managers/SearchManager.d.ts +402 -0
- package/lib/src/managers/ShareManager.d.ts +251 -0
- package/lib/src/managers/TemplateManager.d.ts +176 -0
- package/lib/src/managers/ThemeManager.d.ts +56 -0
- package/lib/src/managers/UserManager.d.ts +480 -0
- package/lib/src/managers/ValidationManager.d.ts +426 -0
- package/lib/src/managers/VariableManager.d.ts +145 -0
- package/lib/src/middleware/csrf.d.ts +3 -0
- package/lib/src/parsers/LinkParser.d.ts +457 -0
- package/lib/src/parsers/MarkupParser.d.ts +769 -0
- package/lib/src/parsers/context/ParseContext.d.ts +314 -0
- package/lib/src/parsers/data/emoji-map.d.ts +18 -0
- package/lib/src/parsers/dom/DOMBuilder.d.ts +210 -0
- package/lib/src/parsers/dom/DOMParser.d.ts +252 -0
- package/lib/src/parsers/dom/Tokenizer.d.ts +358 -0
- package/lib/src/parsers/dom/WikiDocument.d.ts +272 -0
- package/lib/src/parsers/dom/handlers/DOMLinkHandler.d.ts +260 -0
- package/lib/src/parsers/dom/handlers/DOMPluginHandler.d.ts +212 -0
- package/lib/src/parsers/dom/handlers/DOMVariableHandler.d.ts +171 -0
- package/lib/src/parsers/filters/BaseFilter.d.ts +269 -0
- package/lib/src/parsers/filters/FilterChain.d.ts +320 -0
- package/lib/src/parsers/handlers/BaseSyntaxHandler.d.ts +331 -0
- package/lib/src/parsers/handlers/HandlerRegistry.d.ts +225 -0
- package/lib/src/parsers/handlers/JSPWikiPreprocessor.d.ts +89 -0
- package/lib/src/parsers/handlers/LinkParserHandler.d.ts +80 -0
- package/lib/src/parsers/handlers/PluginSyntaxHandler.d.ts +78 -0
- package/lib/src/parsers/handlers/WikiFormHandler.d.ts +149 -0
- package/lib/src/parsers/handlers/WikiTagHandler.d.ts +125 -0
- package/lib/src/parsers/jspwikiTableRow.d.ts +39 -0
- package/lib/src/parsers/styleBlockSyntax.d.ts +14 -0
- package/lib/src/plugins/types.d.ts +79 -0
- package/lib/src/providers/AgentTokenAuthProvider.d.ts +40 -0
- package/lib/src/providers/AuthentikBearerAuthProvider.d.ts +75 -0
- package/lib/src/providers/BaseAttachmentProvider.d.ts +197 -0
- package/lib/src/providers/BaseAuthProvider.d.ts +220 -0
- package/lib/src/providers/BaseCacheProvider.d.ts +140 -0
- package/lib/src/providers/BaseCredentialsProvider.d.ts +84 -0
- package/lib/src/providers/BaseDatabaseProvider.d.ts +22 -0
- package/lib/src/providers/BaseDatabaseProvider.js +12 -0
- package/lib/src/providers/BaseLoggingProvider.d.ts +68 -0
- package/lib/src/providers/BaseLoggingProvider.js +37 -0
- package/lib/src/providers/BaseMediaProvider.d.ts +308 -0
- package/lib/src/providers/BasePageProvider.d.ts +337 -0
- package/lib/src/providers/BaseProvider.d.ts +98 -0
- package/lib/src/providers/BaseProvider.js +74 -0
- package/lib/src/providers/BaseSearchProvider.d.ts +362 -0
- package/lib/src/providers/BasicAttachmentProvider.d.ts +599 -0
- package/lib/src/providers/CloudflareAccessAuthProvider.d.ts +72 -0
- package/lib/src/providers/FileCredentialsProvider.d.ts +33 -0
- package/lib/src/providers/FileJobStateProvider.d.ts +58 -0
- package/lib/src/providers/FileLoggingProvider.d.ts +43 -0
- package/lib/src/providers/FileLoggingProvider.js +120 -0
- package/lib/src/providers/FileOidcAdapter.d.ts +25 -0
- package/lib/src/providers/FileSystemMediaProvider.d.ts +403 -0
- package/lib/src/providers/LunrSearchProvider.d.ts +280 -0
- package/lib/src/providers/MagicLinkAuthProvider.d.ts +125 -0
- package/lib/src/providers/NullCacheProvider.d.ts +72 -0
- package/lib/src/providers/OidcAuthProvider.d.ts +180 -0
- package/lib/src/providers/OidcBearerAuthProvider.d.ts +30 -0
- package/lib/src/providers/PasskeyAuthProvider.d.ts +58 -0
- package/lib/src/providers/PasswordAuthProvider.d.ts +20 -0
- package/lib/src/providers/SqliteDatabaseProvider.d.ts +55 -0
- package/lib/src/providers/SqliteDatabaseProvider.js +90 -0
- package/lib/src/providers/SqliteJobStateProvider.d.ts +41 -0
- package/lib/src/providers/databaseMigrations.d.ts +8 -0
- package/lib/src/providers/databaseMigrations.js +47 -0
- package/lib/src/providers/jobState.d.ts +99 -0
- package/lib/src/providers/lunrQueryToMiniSearch.d.ts +32 -0
- package/lib/src/providers/mediaFilenameDate.d.ts +22 -0
- package/lib/src/providers/sqliteLocation.d.ts +17 -0
- package/lib/src/providers/sqliteLocation.js +55 -0
- package/lib/src/providers/sqliteMigrations.d.ts +48 -0
- package/lib/src/providers/sqliteMigrations.js +72 -0
- package/lib/src/rendering/githubAlerts.d.ts +22 -0
- package/lib/src/rendering/htmlPolicy.d.ts +76 -0
- package/lib/src/rendering/markdownConverter.d.ts +73 -0
- package/lib/src/security/PolicyDecisionPoint.d.ts +245 -0
- package/lib/src/security/PolicyInformationPoint.d.ts +448 -0
- package/lib/src/security/pageActions.d.ts +40 -0
- package/lib/src/security/permissions.generated.d.ts +78 -0
- package/lib/src/types/Asset.d.ts +414 -0
- package/lib/src/types/Catalog.d.ts +61 -0
- package/lib/src/types/Comment.d.ts +11 -0
- package/lib/src/types/Config.d.ts +388 -0
- package/lib/src/types/Organization.d.ts +78 -0
- package/lib/src/types/OrganizationProvider.d.ts +38 -0
- package/lib/src/types/Page.d.ts +220 -0
- package/lib/src/types/Person.d.ts +56 -0
- package/lib/src/types/PersonProvider.d.ts +25 -0
- package/lib/src/types/Policy.d.ts +139 -0
- package/lib/src/types/Policy.js +17 -0
- package/lib/src/types/Provider.d.ts +810 -0
- package/lib/src/types/Role.d.ts +84 -0
- package/lib/src/types/RoleProvider.d.ts +35 -0
- package/lib/src/types/Schema.d.ts +425 -0
- package/lib/src/types/Share.d.ts +172 -0
- package/lib/src/types/Share.js +81 -0
- package/lib/src/types/User.d.ts +304 -0
- package/lib/src/types/Version.d.ts +189 -0
- package/lib/src/types/WikiEngine.d.ts +128 -0
- package/lib/src/types/express.d.ts +120 -0
- package/lib/src/types/express.js +1 -0
- package/lib/src/types/guards.d.ts +78 -0
- package/lib/src/types/heic-decode.d.ts +16 -0
- package/lib/src/types/index.d.ts +19 -0
- package/lib/src/types/linkedom.d.ts +77 -0
- package/lib/src/types/markdown-it-plugins.d.ts +21 -0
- package/lib/src/utils/DeltaStorage.d.ts +118 -0
- package/lib/src/utils/LocaleUtils.d.ts +105 -0
- package/lib/src/utils/LocaleUtils.js +294 -0
- package/lib/src/utils/PageNameMatcher.d.ts +98 -0
- package/lib/src/utils/PathPreflight.d.ts +38 -0
- package/lib/src/utils/PathPreflight.js +50 -0
- package/lib/src/utils/SectionUtils.d.ts +40 -0
- package/lib/src/utils/addonConfigLayer.d.ts +144 -0
- package/lib/src/utils/addonConfigLayer.js +382 -0
- package/lib/src/utils/addonDatabaseRules.d.ts +25 -0
- package/lib/src/utils/addonDatabaseRules.js +57 -0
- package/lib/src/utils/addonHost.d.ts +51 -0
- package/lib/src/utils/addonHost.js +72 -0
- package/lib/src/utils/addonInstaller.d.ts +109 -0
- package/lib/src/utils/addonLock.d.ts +37 -0
- package/lib/src/utils/addonLock.js +48 -0
- package/lib/src/utils/addonPageSync.d.ts +45 -0
- package/lib/src/utils/addonProvenance.d.ts +48 -0
- package/lib/src/utils/addonTarball.d.ts +41 -0
- package/lib/src/utils/addonsPathResolver.d.ts +76 -0
- package/lib/src/utils/addonsPathResolver.js +129 -0
- package/lib/src/utils/assetMetadataTags.d.ts +66 -0
- package/lib/src/utils/atomicWrite.d.ts +28 -0
- package/lib/src/utils/auditConfigChange.d.ts +57 -0
- package/lib/src/utils/auditEventNames.d.ts +50 -0
- package/lib/src/utils/auditEventNames.generated.d.ts +163 -0
- package/lib/src/utils/auditEventNames.generated.js +168 -0
- package/lib/src/utils/auditEventNames.js +49 -0
- package/lib/src/utils/auditEvents.d.ts +265 -0
- package/lib/src/utils/auditEvents.js +309 -0
- package/lib/src/utils/auditRegistry.d.ts +102 -0
- package/lib/src/utils/auditRegistry.js +168 -0
- package/lib/src/utils/bip39English.d.ts +2 -0
- package/lib/src/utils/configEnvKeys.d.ts +68 -0
- package/lib/src/utils/configFiles.d.ts +74 -0
- package/lib/src/utils/configFiles.js +150 -0
- package/lib/src/utils/delegation.d.ts +48 -0
- package/lib/src/utils/deriveUsername.d.ts +21 -0
- package/lib/src/utils/explainMediaPath.d.ts +123 -0
- package/lib/src/utils/formerTitles.d.ts +69 -0
- package/lib/src/utils/frontmatterAccess.d.ts +54 -0
- package/lib/src/utils/headlessAdminPassword.d.ts +55 -0
- package/lib/src/utils/imageTransform.d.ts +52 -0
- package/lib/src/utils/instanceEnvSecret.d.ts +51 -0
- package/lib/src/utils/jobSlots.d.ts +43 -0
- package/lib/src/utils/keywordNormalizer.d.ts +127 -0
- package/lib/src/utils/logger.d.ts +70 -0
- package/lib/src/utils/logger.js +106 -0
- package/lib/src/utils/magicLinkDeviceState.d.ts +95 -0
- package/lib/src/utils/managerUtils.d.ts +40 -0
- package/lib/src/utils/migrateLegacyPrivateAttachments.d.ts +15 -0
- package/lib/src/utils/orgFilename.d.ts +22 -0
- package/lib/src/utils/pageFeature.d.ts +27 -0
- package/lib/src/utils/pageFrontmatter.d.ts +21 -0
- package/lib/src/utils/pageNotes.d.ts +150 -0
- package/lib/src/utils/pageTitleRule.d.ts +37 -0
- package/lib/src/utils/pageToArticle.d.ts +59 -0
- package/lib/src/utils/pageUrl.d.ts +18 -0
- package/lib/src/utils/passwordChange.d.ts +12 -0
- package/lib/src/utils/passwordHash.d.ts +60 -0
- package/lib/src/utils/permissionCatalog.d.ts +52 -0
- package/lib/src/utils/pluginFormatters.d.ts +452 -0
- package/lib/src/utils/pluginFormatters.js +714 -0
- package/lib/src/utils/privateLinkRewrite.d.ts +42 -0
- package/lib/src/utils/privateStoreAccess.d.ts +57 -0
- package/lib/src/utils/privateStoreAccess.js +65 -0
- package/lib/src/utils/privateStoreCatalogs.d.ts +40 -0
- package/lib/src/utils/privateStoreCrypto.d.ts +127 -0
- package/lib/src/utils/privateStoreDoor.d.ts +181 -0
- package/lib/src/utils/privateStoreExport.d.ts +72 -0
- package/lib/src/utils/privateStoreFiles.d.ts +120 -0
- package/lib/src/utils/privateStoreMeta.d.ts +64 -0
- package/lib/src/utils/privateStoreMigrations.d.ts +37 -0
- package/lib/src/utils/privateStorePath.d.ts +259 -0
- package/lib/src/utils/privateStorePath.js +436 -0
- package/lib/src/utils/privateStoreTakeout.d.ts +76 -0
- package/lib/src/utils/privateStoreUnlock.d.ts +175 -0
- package/lib/src/utils/proxyClaim.d.ts +59 -0
- package/lib/src/utils/proxyClaim.js +30 -0
- package/lib/src/utils/redactPrivateNames.d.ts +21 -0
- package/lib/src/utils/redactPrivateNames.js +89 -0
- package/lib/src/utils/redactSecrets.d.ts +58 -0
- package/lib/src/utils/redactSecrets.js +171 -0
- package/lib/src/utils/renameLinkRewrite.d.ts +112 -0
- package/lib/src/utils/roleGrants.d.ts +35 -0
- package/lib/src/utils/safeRedirect.d.ts +32 -0
- package/lib/src/utils/schedule.d.ts +54 -0
- package/lib/src/utils/secretFileMode.d.ts +17 -0
- package/lib/src/utils/seededShippedPages.d.ts +45 -0
- package/lib/src/utils/sensitiveValues.d.ts +59 -0
- package/lib/src/utils/sensitiveValues.js +106 -0
- package/lib/src/utils/sessionEnd.d.ts +12 -0
- package/lib/src/utils/sessionGeneration.d.ts +24 -0
- package/lib/src/utils/sessionIdle.d.ts +41 -0
- package/lib/src/utils/sessionLimits.d.ts +68 -0
- package/lib/src/utils/shareLockbox.d.ts +64 -0
- package/lib/src/utils/sigstoreTrustedRoot.d.ts +33 -0
- package/lib/src/utils/sniffMime.d.ts +21 -0
- package/lib/src/utils/storeSearchIndex.d.ts +75 -0
- package/lib/src/utils/subjectMayDo.d.ts +21 -0
- package/lib/src/utils/systemCategoryRules.d.ts +30 -0
- package/lib/src/utils/systemCategoryRules.js +41 -0
- package/lib/src/utils/userCreateError.d.ts +62 -0
- package/lib/src/utils/username.d.ts +53 -0
- package/lib/src/utils/vaultUrlRewrite.d.ts +27 -0
- package/lib/src/utils/version.d.ts +84 -0
- package/lib/src/utils/videoFrame.d.ts +21 -0
- 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
|
+
'&': '&',
|
|
39
|
+
'<': '<',
|
|
40
|
+
'>': '>',
|
|
41
|
+
'"': '"',
|
|
42
|
+
"'": '''
|
|
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, '* ');
|
|
88
|
+
const safeAfter = processedAfter.replace(/\*/g, '* ');
|
|
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('…', 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('…', 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
|
+
}
|