@hoardodile/ui 0.1.6 → 0.1.8

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 (273) hide show
  1. package/dist/chunk-2BVZEYNW.js +97 -0
  2. package/dist/chunk-2BVZEYNW.js.map +1 -0
  3. package/dist/chunk-2DTR6CD5.js +47 -0
  4. package/dist/chunk-2DTR6CD5.js.map +1 -0
  5. package/dist/chunk-2HWSCY4T.js +20 -0
  6. package/dist/chunk-2HWSCY4T.js.map +1 -0
  7. package/dist/chunk-363CCKQ7.js +75 -0
  8. package/dist/chunk-363CCKQ7.js.map +1 -0
  9. package/dist/chunk-3ZQJWBVB.js +30 -0
  10. package/dist/chunk-3ZQJWBVB.js.map +1 -0
  11. package/dist/chunk-4D36OO5W.js +277 -0
  12. package/dist/chunk-4D36OO5W.js.map +1 -0
  13. package/dist/chunk-4UITFLIN.js +31 -0
  14. package/dist/chunk-4UITFLIN.js.map +1 -0
  15. package/dist/chunk-6HAIUXNZ.js +11 -0
  16. package/dist/chunk-6HAIUXNZ.js.map +1 -0
  17. package/dist/chunk-7JMMFY67.js +62 -0
  18. package/dist/chunk-7JMMFY67.js.map +1 -0
  19. package/dist/chunk-7MWWHI6T.js +1149 -0
  20. package/dist/chunk-7MWWHI6T.js.map +1 -0
  21. package/dist/chunk-AEHTAUAQ.js +65 -0
  22. package/dist/chunk-AEHTAUAQ.js.map +1 -0
  23. package/dist/chunk-ASRGQ5BD.js +130 -0
  24. package/dist/chunk-ASRGQ5BD.js.map +1 -0
  25. package/dist/chunk-BFGOE5AI.js +187 -0
  26. package/dist/chunk-BFGOE5AI.js.map +1 -0
  27. package/dist/chunk-BRT25IDQ.js +46 -0
  28. package/dist/chunk-BRT25IDQ.js.map +1 -0
  29. package/dist/chunk-BU5DQOPM.js +29 -0
  30. package/dist/chunk-BU5DQOPM.js.map +1 -0
  31. package/dist/chunk-BWASOT5X.js +266 -0
  32. package/dist/chunk-BWASOT5X.js.map +1 -0
  33. package/dist/chunk-F32V4Z7Q.js +26 -0
  34. package/dist/chunk-F32V4Z7Q.js.map +1 -0
  35. package/dist/chunk-F7RHDRRJ.js +24 -0
  36. package/dist/chunk-F7RHDRRJ.js.map +1 -0
  37. package/dist/chunk-FH5RNCFR.js +21 -0
  38. package/dist/chunk-FH5RNCFR.js.map +1 -0
  39. package/dist/chunk-GKSX5ULZ.js +513 -0
  40. package/dist/chunk-GKSX5ULZ.js.map +1 -0
  41. package/dist/chunk-JPFDNRCA.js +26 -0
  42. package/dist/chunk-JPFDNRCA.js.map +1 -0
  43. package/dist/chunk-KNPRUZ4H.js +13 -0
  44. package/dist/chunk-KNPRUZ4H.js.map +1 -0
  45. package/dist/chunk-KOFWEPPB.js +254 -0
  46. package/dist/chunk-KOFWEPPB.js.map +1 -0
  47. package/dist/chunk-M5OJN54O.js +10 -0
  48. package/dist/chunk-M5OJN54O.js.map +1 -0
  49. package/dist/chunk-OEVALKQ5.js +51 -0
  50. package/dist/chunk-OEVALKQ5.js.map +1 -0
  51. package/dist/chunk-OW5FWLPH.js +33 -0
  52. package/dist/chunk-OW5FWLPH.js.map +1 -0
  53. package/dist/chunk-POX7TXBH.js +41 -0
  54. package/dist/chunk-POX7TXBH.js.map +1 -0
  55. package/dist/chunk-PWJBHUM2.js +58 -0
  56. package/dist/chunk-PWJBHUM2.js.map +1 -0
  57. package/dist/chunk-QGMDWGJT.js +23 -0
  58. package/dist/chunk-QGMDWGJT.js.map +1 -0
  59. package/dist/chunk-QGTQQNUL.js +36 -0
  60. package/dist/chunk-QGTQQNUL.js.map +1 -0
  61. package/dist/chunk-SAY52ZKI.js +152 -0
  62. package/dist/chunk-SAY52ZKI.js.map +1 -0
  63. package/dist/chunk-SKDPGSRL.js +166 -0
  64. package/dist/chunk-SKDPGSRL.js.map +1 -0
  65. package/dist/chunk-T56ARCUK.js +38 -0
  66. package/dist/chunk-T56ARCUK.js.map +1 -0
  67. package/dist/chunk-TDDHQZ62.js +227 -0
  68. package/dist/chunk-TDDHQZ62.js.map +1 -0
  69. package/dist/chunk-TMQ5OMBK.js +29 -0
  70. package/dist/chunk-TMQ5OMBK.js.map +1 -0
  71. package/dist/chunk-UEVND7AS.js +23 -0
  72. package/dist/chunk-UEVND7AS.js.map +1 -0
  73. package/dist/chunk-UPWHCG4S.js +40 -0
  74. package/dist/chunk-UPWHCG4S.js.map +1 -0
  75. package/dist/chunk-VWUNF7VX.js +3 -0
  76. package/dist/chunk-VWUNF7VX.js.map +1 -0
  77. package/dist/chunk-WI6O7GZK.js +45 -0
  78. package/dist/chunk-WI6O7GZK.js.map +1 -0
  79. package/dist/chunk-WVEK3WCX.js +21 -0
  80. package/dist/chunk-WVEK3WCX.js.map +1 -0
  81. package/dist/chunk-XBPQEZES.js +48 -0
  82. package/dist/chunk-XBPQEZES.js.map +1 -0
  83. package/dist/chunk-ZYAGKKR7.js +142 -0
  84. package/dist/chunk-ZYAGKKR7.js.map +1 -0
  85. package/dist/components/alert.js +2 -21
  86. package/dist/components/alert.js.map +1 -1
  87. package/dist/components/app-dialog.js +10 -654
  88. package/dist/components/app-dialog.js.map +1 -1
  89. package/dist/components/badge.js +2 -23
  90. package/dist/components/badge.js.map +1 -1
  91. package/dist/components/breadcrumb.js +5 -501
  92. package/dist/components/breadcrumb.js.map +1 -1
  93. package/dist/components/button.js +3 -76
  94. package/dist/components/button.js.map +1 -1
  95. package/dist/components/caption-bar.js +7 -518
  96. package/dist/components/caption-bar.js.map +1 -1
  97. package/dist/components/checkbox.js +4 -74
  98. package/dist/components/checkbox.js.map +1 -1
  99. package/dist/components/chip-button.js +4 -85
  100. package/dist/components/chip-button.js.map +1 -1
  101. package/dist/components/close-confirm-dialog.js +12 -662
  102. package/dist/components/close-confirm-dialog.js.map +1 -1
  103. package/dist/components/color-picker.js +7 -530
  104. package/dist/components/color-picker.js.map +1 -1
  105. package/dist/components/combobox.js +15 -934
  106. package/dist/components/combobox.js.map +1 -1
  107. package/dist/components/confirm-by-typing-dialog.js +12 -669
  108. package/dist/components/confirm-by-typing-dialog.js.map +1 -1
  109. package/dist/components/confirm-dialog.js +11 -637
  110. package/dist/components/confirm-dialog.js.map +1 -1
  111. package/dist/components/count-badge.js +2 -21
  112. package/dist/components/count-badge.js.map +1 -1
  113. package/dist/components/dialog.js +8 -569
  114. package/dist/components/dialog.js.map +1 -1
  115. package/dist/components/dropdown-menu.js +9 -1004
  116. package/dist/components/dropdown-menu.js.map +1 -1
  117. package/dist/components/dropdown-select.js +9 -880
  118. package/dist/components/dropdown-select.js.map +1 -1
  119. package/dist/components/empty.js +2 -21
  120. package/dist/components/empty.js.map +1 -1
  121. package/dist/components/filter-rail.js +7 -145
  122. package/dist/components/filter-rail.js.map +1 -1
  123. package/dist/components/fixed-action-bar.js +2 -21
  124. package/dist/components/fixed-action-bar.js.map +1 -1
  125. package/dist/components/font-picker.js +11 -586
  126. package/dist/components/font-picker.js.map +1 -1
  127. package/dist/components/form.js +9 -66
  128. package/dist/components/form.js.map +1 -1
  129. package/dist/components/icon-tile.js +3 -43
  130. package/dist/components/icon-tile.js.map +1 -1
  131. package/dist/components/icon-toggle.js +3 -43
  132. package/dist/components/icon-toggle.js.map +1 -1
  133. package/dist/components/icon.js +3 -47
  134. package/dist/components/icon.js.map +1 -1
  135. package/dist/components/image-crop-panel.js +19 -772
  136. package/dist/components/image-crop-panel.js.map +1 -1
  137. package/dist/components/image-cropper.js +1 -164
  138. package/dist/components/image-cropper.js.map +1 -1
  139. package/dist/components/input-group.js +6 -268
  140. package/dist/components/input-group.js.map +1 -1
  141. package/dist/components/input.js +3 -58
  142. package/dist/components/input.js.map +1 -1
  143. package/dist/components/label.js +3 -38
  144. package/dist/components/label.js.map +1 -1
  145. package/dist/components/list-empty-row.js +2 -21
  146. package/dist/components/list-empty-row.js.map +1 -1
  147. package/dist/components/management-empty.js +2 -21
  148. package/dist/components/management-empty.js.map +1 -1
  149. package/dist/components/management-skeleton.js +4 -59
  150. package/dist/components/management-skeleton.js.map +1 -1
  151. package/dist/components/meta-chip.js +2 -21
  152. package/dist/components/meta-chip.js.map +1 -1
  153. package/dist/components/mobile-drawer.js +7 -268
  154. package/dist/components/mobile-drawer.js.map +1 -1
  155. package/dist/components/page-header.js +3 -42
  156. package/dist/components/page-header.js.map +1 -1
  157. package/dist/components/page-scaffold.js +5 -76
  158. package/dist/components/page-scaffold.js.map +1 -1
  159. package/dist/components/pagination-bar.js +13 -614
  160. package/dist/components/pagination-bar.js.map +1 -1
  161. package/dist/components/pagination.js +6 -681
  162. package/dist/components/pagination.js.map +1 -1
  163. package/dist/components/panel-toolbar.js +11 -713
  164. package/dist/components/panel-toolbar.js.map +1 -1
  165. package/dist/components/pill-tabs.js +2 -21
  166. package/dist/components/pill-tabs.js.map +1 -1
  167. package/dist/components/plugin-download-consent.js +13 -663
  168. package/dist/components/plugin-download-consent.js.map +1 -1
  169. package/dist/components/popover.js +5 -242
  170. package/dist/components/popover.js.map +1 -1
  171. package/dist/components/preview-card.js +2 -21
  172. package/dist/components/preview-card.js.map +1 -1
  173. package/dist/components/progress.js +2 -20
  174. package/dist/components/progress.js.map +1 -1
  175. package/dist/components/radio-group.js +2 -21
  176. package/dist/components/radio-group.js.map +1 -1
  177. package/dist/components/scroll-area.js +2 -21
  178. package/dist/components/scroll-area.js.map +1 -1
  179. package/dist/components/search-field.js +6 -587
  180. package/dist/components/search-field.js.map +1 -1
  181. package/dist/components/secondary-stat.js +3 -40
  182. package/dist/components/secondary-stat.js.map +1 -1
  183. package/dist/components/section-header.js +3 -43
  184. package/dist/components/section-header.js.map +1 -1
  185. package/dist/components/section-label.js +3 -44
  186. package/dist/components/section-label.js.map +1 -1
  187. package/dist/components/section-tabs.js +2 -21
  188. package/dist/components/section-tabs.js.map +1 -1
  189. package/dist/components/separator.js +3 -47
  190. package/dist/components/separator.js.map +1 -1
  191. package/dist/components/sheet.js +8 -467
  192. package/dist/components/sheet.js.map +1 -1
  193. package/dist/components/sidebar.js +24 -1044
  194. package/dist/components/sidebar.js.map +1 -1
  195. package/dist/components/skeleton.js +3 -39
  196. package/dist/components/skeleton.js.map +1 -1
  197. package/dist/components/slider.js +2 -21
  198. package/dist/components/slider.js.map +1 -1
  199. package/dist/components/sortable-chip-list.js +2 -21
  200. package/dist/components/sortable-chip-list.js.map +1 -1
  201. package/dist/components/special-tag-surface.js +3 -531
  202. package/dist/components/special-tag-surface.js.map +1 -1
  203. package/dist/components/spinner.js +3 -63
  204. package/dist/components/spinner.js.map +1 -1
  205. package/dist/components/surface.js +3 -49
  206. package/dist/components/surface.js.map +1 -1
  207. package/dist/components/switch.js +3 -51
  208. package/dist/components/switch.js.map +1 -1
  209. package/dist/components/table.js +2 -20
  210. package/dist/components/table.js.map +1 -1
  211. package/dist/components/tag-chip.js +6 -617
  212. package/dist/components/tag-chip.js.map +1 -1
  213. package/dist/components/textarea.js +3 -56
  214. package/dist/components/textarea.js.map +1 -1
  215. package/dist/components/toast.js +8 -806
  216. package/dist/components/toast.js.map +1 -1
  217. package/dist/components/toggle.js +2 -20
  218. package/dist/components/toggle.js.map +1 -1
  219. package/dist/components/tooltip.js +3 -80
  220. package/dist/components/tooltip.js.map +1 -1
  221. package/dist/hooks/use-dialog-footer-actions.js +1 -9
  222. package/dist/hooks/use-dialog-footer-actions.js.map +1 -1
  223. package/dist/hooks/use-form-field.js +1 -34
  224. package/dist/hooks/use-form-field.js.map +1 -1
  225. package/dist/hooks/use-mobile.js +2 -27
  226. package/dist/hooks/use-mobile.js.map +1 -1
  227. package/dist/hooks/useMobileBackToClose.js +3 -232
  228. package/dist/hooks/useMobileBackToClose.js.map +1 -1
  229. package/dist/icons/actions.js +4 -521
  230. package/dist/icons/actions.js.map +1 -1
  231. package/dist/icons/icon-style.js +1 -49
  232. package/dist/icons/icon-style.js.map +1 -1
  233. package/dist/icons/marks.js +1 -46
  234. package/dist/icons/marks.js.map +1 -1
  235. package/dist/icons/registry.d.ts +2 -1
  236. package/dist/icons/registry.js +2 -1186
  237. package/dist/icons/registry.js.map +1 -1
  238. package/dist/index.js +10 -651
  239. package/dist/index.js.map +1 -1
  240. package/dist/lib/colors.js +1 -63
  241. package/dist/lib/colors.js.map +1 -1
  242. package/dist/lib/merge-config.js +1 -18
  243. package/dist/lib/merge-config.js.map +1 -1
  244. package/dist/lib/pagination.js +1 -24
  245. package/dist/lib/pagination.js.map +1 -1
  246. package/dist/lib/tag-surface.js +5 -613
  247. package/dist/lib/tag-surface.js.map +1 -1
  248. package/dist/lib/utils.js +2 -24
  249. package/dist/lib/utils.js.map +1 -1
  250. package/dist/res-card-template/format.d.ts +21 -0
  251. package/dist/res-card-template/format.js +3 -0
  252. package/dist/res-card-template/format.js.map +1 -0
  253. package/dist/res-card-template/icon.d.ts +38 -0
  254. package/dist/res-card-template/icon.js +3 -0
  255. package/dist/res-card-template/icon.js.map +1 -0
  256. package/dist/res-card-template/index.d.ts +4 -0
  257. package/dist/res-card-template/index.js +5 -0
  258. package/dist/res-card-template/index.js.map +1 -0
  259. package/dist/res-card-template/template.d.ts +48 -0
  260. package/dist/res-card-template/template.js +5 -0
  261. package/dist/res-card-template/template.js.map +1 -0
  262. package/dist/viewport.js +1 -11
  263. package/dist/viewport.js.map +1 -1
  264. package/package.json +9 -2
  265. package/src/components/image-crop-panel.dist.test.tsx +54 -0
  266. package/src/icons/registry.ts +8 -0
  267. package/src/res-card-template/format.test.ts +45 -0
  268. package/src/res-card-template/format.ts +36 -0
  269. package/src/res-card-template/icon.test.ts +100 -0
  270. package/src/res-card-template/icon.ts +86 -0
  271. package/src/res-card-template/index.ts +22 -0
  272. package/src/res-card-template/template.test.ts +457 -0
  273. package/src/res-card-template/template.ts +429 -0
@@ -0,0 +1,86 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ /**
4
+ * A resolved template-icon reference: a Solar glyph name, or an
5
+ * asset image URL derived from a manifest-relative path.
6
+ */
7
+ export type IconRef =
8
+ | { readonly kind: "icon"; readonly name: string }
9
+ | { readonly kind: "asset"; readonly url: string }
10
+
11
+ /**
12
+ * Legacy whitelist name → Solar kebab glyph. Derived names are handled by
13
+ * the PascalCase conversion; only these diverge.
14
+ */
15
+ const LEGACY_ICON_ALIASES: Readonly<Record<string, string>> = {
16
+ Files: "file",
17
+ Film: "video-frame",
18
+ Image: "gallery",
19
+ Info: "info-circle",
20
+ Music: "music-notes",
21
+ Search: "magnifier",
22
+ Sparkle: "star",
23
+ Video: "video-frame",
24
+ }
25
+
26
+ /**
27
+ * Normalize a raw icon name to Solar kebab-case, or `undefined` when the
28
+ * string cannot name a glyph at all (schemes, separators, punctuation).
29
+ * This is syntax only — membership in the Solar glyph index is left to
30
+ * the renderer that consumes the name.
31
+ */
32
+ export function normalizeSolarGlyphName(raw: string): string | undefined {
33
+ const trimmed = raw.trim()
34
+ if (trimmed.length === 0) return undefined
35
+ const aliased = LEGACY_ICON_ALIASES[trimmed]
36
+ if (aliased !== undefined) return aliased
37
+ if (/^[a-z][a-z0-9-]*$/.test(trimmed)) return trimmed
38
+ if (/^[A-Za-z][A-Za-z0-9]*$/.test(trimmed)) {
39
+ return trimmed.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
40
+ }
41
+ return undefined
42
+ }
43
+
44
+ /**
45
+ * Parse a manifest-level icon string into a render-ready ref.
46
+ *
47
+ * `<SolarGlyph>` — Solar glyph name (manifest/template icons are
48
+ * Solar-only; PascalCase and the legacy whitelist
49
+ * names normalize to the kebab glyph)
50
+ * `<relative/path>` — resolved through `buildAssetUrl(pluginId, path)`
51
+ * (leading `./` stripped)
52
+ *
53
+ * Empty inputs, schemes (`http(s)`, `data:`), `..`-shaped paths and any
54
+ * string that cannot name a glyph return `undefined`. The renderer treats
55
+ * that as "nothing" — icon resolution never throws.
56
+ */
57
+ export function parseIconRef(
58
+ raw: string,
59
+ pluginId: string,
60
+ buildAssetUrl: (pluginId: string, path: string) => string,
61
+ ): IconRef | undefined {
62
+ const trimmed = raw.trim()
63
+ if (trimmed.length === 0) return undefined
64
+ if (
65
+ trimmed.startsWith("http://") ||
66
+ trimmed.startsWith("https://") ||
67
+ trimmed.startsWith("data:")
68
+ ) {
69
+ return undefined
70
+ }
71
+ if (
72
+ trimmed.includes(".") ||
73
+ trimmed.includes("/") ||
74
+ trimmed.includes("\\")
75
+ ) {
76
+ const rel = trimmed.replace(/^\.[\\/]/, "")
77
+ if (rel.length === 0 || rel.split("/").includes("..")) return undefined
78
+ return { kind: "asset", url: buildAssetUrl(pluginId, rel) }
79
+ }
80
+ const name = normalizeSolarGlyphName(trimmed)
81
+ if (name === undefined) return undefined
82
+ return { kind: "icon", name }
83
+ }
84
+
85
+ /** How a parsed {@link IconRef} is drawn inside a rendered badge/template. */
86
+ export type RenderIcon = (ref: IconRef, className?: string) => ReactNode
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Res-card template renderer, shared by the host app and the offline
3
+ * workbench. Evaluates the plugin manifest's `{{...}}` template strings
4
+ * (`ui.card.*`, `ui.search.*`, `ui.message.anchor`) over a resource's
5
+ * hook snapshot, returning ReactNode (so icon-producing directives can
6
+ * inject components inline). Icon drawing and asset-URL building are
7
+ * injected by the caller, so this module never imports host-specific
8
+ * code — see the grammar in `@hoardodile/sdk-types/template`.
9
+ */
10
+ export { formatBytes, formatClockDuration } from "./format.ts"
11
+ export {
12
+ type IconRef,
13
+ normalizeSolarGlyphName,
14
+ parseIconRef,
15
+ type RenderIcon,
16
+ } from "./icon.ts"
17
+ export {
18
+ renderCardTemplate,
19
+ renderSlotBadges,
20
+ resolveLocaleString,
21
+ type TemplateContext,
22
+ } from "./template.ts"
@@ -0,0 +1,457 @@
1
+ /**
2
+ * @vitest-environment node
3
+ */
4
+
5
+ import { createElement } from "react"
6
+ import { describe, expect, test } from "vitest"
7
+ import { type IconRef, normalizeSolarGlyphName } from "./icon.ts"
8
+ import {
9
+ type RenderIcon,
10
+ renderCardTemplate,
11
+ renderSlotBadges,
12
+ resolveLocaleString,
13
+ type TemplateContext,
14
+ } from "./index.ts"
15
+
16
+ // A deterministic in-memory icon renderer: a fixed set of "known" Solar
17
+ // glyphs (what the app's renderer considers resolvable) draw a React
18
+ // element (so mixed templates stay arrays), unknown glyphs and
19
+ // unresolvable assets return null. This keeps the renderer tests focused
20
+ // on evaluation, not the icon implementation.
21
+ const KNOWN_GLYPHS = new Set([
22
+ "video-frame",
23
+ "music-notes",
24
+ "gallery",
25
+ "heart",
26
+ "play",
27
+ "file-text",
28
+ ])
29
+
30
+ function renderIcon(ref: IconRef): ReturnType<RenderIcon> {
31
+ if (ref.kind === "asset") {
32
+ return createElement("span", null, `asset:${ref.url}`)
33
+ }
34
+ const name = normalizeSolarGlyphName(ref.name)
35
+ return name !== undefined && KNOWN_GLYPHS.has(name)
36
+ ? createElement("span", null, `icon:${name}`)
37
+ : null
38
+ }
39
+
40
+ function makeCtx(overrides?: Partial<TemplateContext>): TemplateContext {
41
+ return {
42
+ locale: "en",
43
+ pluginId: "test-plugin",
44
+ manifest: {
45
+ i18n: {
46
+ name: { en: "Test Plugin", zh: "测试插件" },
47
+ label: { en: "Label", zh: "标签" },
48
+ },
49
+ ui: {
50
+ search: {
51
+ kinds: [
52
+ { key: "video", icon: "{{icon('Video')}}" },
53
+ { key: "audio", icon: "{{icon('Music')}}" },
54
+ { key: "image", icon: "{{icon('Image')}}" },
55
+ ],
56
+ },
57
+ },
58
+ },
59
+ iconClassName: "h-4 w-4",
60
+ renderIcon,
61
+ buildAssetUrl: (pluginId, path) => `/assets/${pluginId}/${path}`,
62
+ ...overrides,
63
+ }
64
+ }
65
+
66
+ describe("renderCardTemplate", () => {
67
+ test("substitutes a simple field", () => {
68
+ const result = renderCardTemplate(
69
+ "{{file.sizeBytes}}",
70
+ {
71
+ file: { sizeBytes: 1024 },
72
+ source: undefined,
73
+ },
74
+ makeCtx(),
75
+ )
76
+ expect(result).toBe("1024")
77
+ })
78
+
79
+ test("applies bytes formatting", () => {
80
+ const result = renderCardTemplate(
81
+ "{{bytes(file.sizeBytes)}}",
82
+ { file: { sizeBytes: 1536 }, source: undefined },
83
+ makeCtx(),
84
+ )
85
+ expect(result).toBe("1.5 KB")
86
+ })
87
+
88
+ test("applies duration formatting", () => {
89
+ const result = renderCardTemplate(
90
+ "{{duration(source.durationMs)}}",
91
+ { file: undefined, source: { durationMs: 125_000 } },
92
+ makeCtx(),
93
+ )
94
+ expect(result).toBe("2:05")
95
+ })
96
+
97
+ test("formats long durations with hours", () => {
98
+ const result = renderCardTemplate(
99
+ "{{duration(source.durationMs)}}",
100
+ { file: undefined, source: { durationMs: 3_661_000 } },
101
+ makeCtx(),
102
+ )
103
+ expect(result).toBe("1:01:01")
104
+ })
105
+
106
+ test("applies number formatting", () => {
107
+ const result = renderCardTemplate(
108
+ "{{number(file.count)}}",
109
+ { file: { count: 1234 }, source: undefined },
110
+ makeCtx(),
111
+ )
112
+ expect(result).toBe("1,234")
113
+ })
114
+
115
+ test("reads from source namespace", () => {
116
+ const result = renderCardTemplate(
117
+ "{{source.width}}×{{source.height}}",
118
+ { file: undefined, source: { width: 1920, height: 1080 } },
119
+ makeCtx(),
120
+ )
121
+ expect(result).toBe("1920×1080")
122
+ })
123
+
124
+ test("renders empty for missing fields", () => {
125
+ const result = renderCardTemplate(
126
+ "{{source.missing}}",
127
+ { file: undefined, source: {}, searchMeta: undefined },
128
+ makeCtx(),
129
+ )
130
+ expect(result).toBeNull()
131
+ })
132
+
133
+ test("renders empty when namespace root is undefined", () => {
134
+ const result = renderCardTemplate(
135
+ "{{file.sizeBytes}}",
136
+ { file: undefined, source: undefined, searchMeta: undefined },
137
+ makeCtx(),
138
+ )
139
+ expect(result).toBeNull()
140
+ })
141
+
142
+ test("leaves plain text untouched", () => {
143
+ const result = renderCardTemplate(
144
+ "hello world",
145
+ { file: undefined, source: undefined, searchMeta: undefined },
146
+ makeCtx(),
147
+ )
148
+ expect(result).toBe("hello world")
149
+ })
150
+
151
+ test("t() resolves i18n key", () => {
152
+ const result = renderCardTemplate(
153
+ "{{t('name')}}",
154
+ { file: undefined, source: undefined, searchMeta: undefined },
155
+ makeCtx(),
156
+ )
157
+ expect(result).toBe("Test Plugin")
158
+ })
159
+
160
+ test("t() falls back to first key when locale missing", () => {
161
+ const result = renderCardTemplate(
162
+ "{{t('name')}}",
163
+ { file: undefined, source: undefined, searchMeta: undefined },
164
+ makeCtx({ locale: "fr" }),
165
+ )
166
+ expect(result).toBe("Test Plugin")
167
+ })
168
+
169
+ test("t() returns empty for missing key", () => {
170
+ const result = renderCardTemplate(
171
+ "{{t('missing')}}",
172
+ { file: undefined, source: undefined, searchMeta: undefined },
173
+ makeCtx(),
174
+ )
175
+ expect(result).toBeNull()
176
+ })
177
+
178
+ test("kind() renders known kind icon", () => {
179
+ const result = renderCardTemplate(
180
+ "{{kind('video')}}",
181
+ { file: undefined, source: undefined, searchMeta: undefined },
182
+ makeCtx(),
183
+ )
184
+ expect(result).not.toBeNull()
185
+ expect(result).not.toBe("")
186
+ })
187
+
188
+ test("kind() returns null for unknown kind", () => {
189
+ const result = renderCardTemplate(
190
+ "{{kind('unknown')}}",
191
+ { file: undefined, source: undefined, searchMeta: undefined },
192
+ makeCtx(),
193
+ )
194
+ expect(result).toBeNull()
195
+ })
196
+
197
+ test("icon() renders known icon", () => {
198
+ const result = renderCardTemplate(
199
+ "{{icon('Heart')}}",
200
+ { file: undefined, source: undefined, searchMeta: undefined },
201
+ makeCtx(),
202
+ )
203
+ expect(result).not.toBeNull()
204
+ expect(result).not.toBe("")
205
+ })
206
+
207
+ test("icon() returns null for unknown name", () => {
208
+ const result = renderCardTemplate(
209
+ "{{icon('NotInRegistry')}}",
210
+ { file: undefined, source: undefined, searchMeta: undefined },
211
+ makeCtx(),
212
+ )
213
+ expect(result).toBeNull()
214
+ })
215
+
216
+ test("asset() renders img node", () => {
217
+ const result = renderCardTemplate(
218
+ "{{asset('icons/heart.gif')}}",
219
+ { file: undefined, source: undefined, searchMeta: undefined },
220
+ makeCtx({ pluginId: "my-plugin" }),
221
+ )
222
+ expect(result).not.toBeNull()
223
+ expect(result).not.toBe("")
224
+ })
225
+
226
+ test("mixed text and icon returns array", () => {
227
+ const result = renderCardTemplate(
228
+ "'{{icon('Play')}}",
229
+ { file: undefined, source: undefined, searchMeta: undefined },
230
+ makeCtx(),
231
+ )
232
+ expect(Array.isArray(result)).toBe(true)
233
+ })
234
+
235
+ test("invalid expression renders empty", () => {
236
+ const result = renderCardTemplate(
237
+ "{{}}",
238
+ { file: undefined, source: undefined, searchMeta: undefined },
239
+ makeCtx(),
240
+ )
241
+ expect(result).toBeNull()
242
+ })
243
+
244
+ test("malformed expression renders empty", () => {
245
+ const result = renderCardTemplate(
246
+ "{{!!!}}",
247
+ { file: undefined, source: undefined, searchMeta: undefined },
248
+ makeCtx(),
249
+ )
250
+ expect(result).toBeNull()
251
+ })
252
+ })
253
+
254
+ describe("resolveLocaleString", () => {
255
+ test("returns a plain string as-is", () => {
256
+ expect(resolveLocaleString("hello", "en")).toBe("hello")
257
+ })
258
+
259
+ test("picks the exact locale match", () => {
260
+ expect(resolveLocaleString({ en: "hello", zh: "你好" }, "zh")).toBe("你好")
261
+ })
262
+
263
+ test("falls back to base language for region codes", () => {
264
+ expect(resolveLocaleString({ en: "hello", zh: "你好" }, "zh-CN")).toBe(
265
+ "你好",
266
+ )
267
+ })
268
+
269
+ test("falls back to first available when nothing matches", () => {
270
+ expect(resolveLocaleString({ de: "Hallo" }, "en")).toBe("Hallo")
271
+ })
272
+ })
273
+
274
+ describe("renderSlotBadges", () => {
275
+ test("renders and filters empty results", () => {
276
+ const result = renderSlotBadges(
277
+ ["{{bytes(file.sizeBytes)}}", "{{source.missing}}"],
278
+ { file: { sizeBytes: 1024 }, source: {}, searchMeta: undefined },
279
+ makeCtx(),
280
+ )
281
+ expect(result).toHaveLength(1)
282
+ expect(result[0]).toBe("1 KB")
283
+ })
284
+
285
+ test("slot value embeds {{t('key')}}", () => {
286
+ const result = renderSlotBadges(
287
+ ["Label: {{t('label')}}, Bytes: {{bytes(file.sizeBytes)}}"],
288
+ { file: { sizeBytes: 1024 }, source: {}, searchMeta: undefined },
289
+ makeCtx({ locale: "zh" }),
290
+ )
291
+ expect(result).toHaveLength(1)
292
+ expect(result[0]).toBe("Label: 标签, Bytes: 1 KB")
293
+ })
294
+
295
+ test("kind() paints without facets; searchKindIcons() does not", () => {
296
+ const scope = {
297
+ file: undefined,
298
+ source: undefined,
299
+ searchMeta: undefined,
300
+ }
301
+ expect(
302
+ renderSlotBadges(["{{kind('video')}}"], scope, makeCtx()),
303
+ ).toHaveLength(1)
304
+ expect(
305
+ renderSlotBadges(
306
+ ["{{join(' ', searchKindIcons(), number(source.animationCount))}}"],
307
+ scope,
308
+ makeCtx(),
309
+ ),
310
+ ).toHaveLength(0)
311
+ })
312
+
313
+ test("searchKindIcons() only includes active facets", () => {
314
+ const result = renderSlotBadges(
315
+ ["{{join(' ', searchKindIcons(), number(source.animationCount))}}"],
316
+ {
317
+ file: undefined,
318
+ source: { animationCount: 3 },
319
+ searchMeta: { v: 1, facets: { video: true, audio: false } },
320
+ },
321
+ makeCtx(),
322
+ )
323
+ expect(result).toHaveLength(1)
324
+ expect(result[0]).not.toBe("")
325
+ })
326
+ })
327
+
328
+ describe("condition syntax", () => {
329
+ test("eq compares primitives", () => {
330
+ expect(
331
+ renderCardTemplate(
332
+ "{{if(eq(file.count, 2), 'yes')}}",
333
+ { file: { count: 2 }, source: {}, searchMeta: undefined },
334
+ makeCtx(),
335
+ ),
336
+ ).toBe("yes")
337
+ })
338
+
339
+ test("eq returns empty when not equal", () => {
340
+ expect(
341
+ renderCardTemplate(
342
+ "{{if(eq(file.count, 2), 'yes')}}",
343
+ { file: { count: 3 }, source: {}, searchMeta: undefined },
344
+ makeCtx(),
345
+ ),
346
+ ).toBeNull()
347
+ })
348
+
349
+ test("gt compares numbers", () => {
350
+ expect(
351
+ renderCardTemplate(
352
+ "{{if(gt(file.count, 1), file.count)}}",
353
+ { file: { count: 5 }, source: {}, searchMeta: undefined },
354
+ makeCtx(),
355
+ ),
356
+ ).toBe(5)
357
+ })
358
+
359
+ test("gt returns empty when not greater", () => {
360
+ expect(
361
+ renderCardTemplate(
362
+ "{{if(gt(file.count, 1), file.count)}}",
363
+ { file: { count: 1 }, source: {}, searchMeta: undefined },
364
+ makeCtx(),
365
+ ),
366
+ ).toBeNull()
367
+ })
368
+
369
+ test("lt compares numbers", () => {
370
+ expect(
371
+ renderCardTemplate(
372
+ "{{if(lt(file.count, 3), 'small')}}",
373
+ { file: { count: 2 }, source: {}, searchMeta: undefined },
374
+ makeCtx(),
375
+ ),
376
+ ).toBe("small")
377
+ })
378
+
379
+ test("gte compares numbers", () => {
380
+ expect(
381
+ renderCardTemplate(
382
+ "{{if(gte(file.count, 1), 'ok')}}",
383
+ { file: { count: 1 }, source: {}, searchMeta: undefined },
384
+ makeCtx(),
385
+ ),
386
+ ).toBe("ok")
387
+ })
388
+
389
+ test("lte compares numbers", () => {
390
+ expect(
391
+ renderCardTemplate(
392
+ "{{if(lte(file.count, 1), 'single')}}",
393
+ { file: { count: 1 }, source: {}, searchMeta: undefined },
394
+ makeCtx(),
395
+ ),
396
+ ).toBe("single")
397
+ })
398
+
399
+ test("ne compares primitives", () => {
400
+ expect(
401
+ renderCardTemplate(
402
+ "{{if(ne(file.count, 1), 'many')}}",
403
+ { file: { count: 5 }, source: {}, searchMeta: undefined },
404
+ makeCtx(),
405
+ ),
406
+ ).toBe("many")
407
+ })
408
+
409
+ test("if with else branch", () => {
410
+ expect(
411
+ renderCardTemplate(
412
+ "{{if(gt(file.count, 1), file.count, 'one')}}",
413
+ { file: { count: 1 }, source: {}, searchMeta: undefined },
414
+ makeCtx(),
415
+ ),
416
+ ).toBe("one")
417
+ })
418
+
419
+ test("number literals as arguments", () => {
420
+ expect(
421
+ renderCardTemplate(
422
+ "{{if(eq(file.count, 1), 'single')}}",
423
+ { file: { count: 1 }, source: {}, searchMeta: undefined },
424
+ makeCtx(),
425
+ ),
426
+ ).toBe("single")
427
+ })
428
+
429
+ test("nested calls", () => {
430
+ expect(
431
+ renderCardTemplate(
432
+ "{{bytes(if(gt(file.sizeBytes, 1024), file.sizeBytes))}}",
433
+ { file: { sizeBytes: 2048 }, source: {}, searchMeta: undefined },
434
+ makeCtx(),
435
+ ),
436
+ ).toBe("2 KB")
437
+ })
438
+
439
+ test("hide count when single file", () => {
440
+ const result = renderSlotBadges(
441
+ ["{{if(gt(file.count, 1), file.count)}}"],
442
+ { file: { count: 1 }, source: {}, searchMeta: undefined },
443
+ makeCtx(),
444
+ )
445
+ expect(result).toHaveLength(0)
446
+ })
447
+
448
+ test("show count when multiple files", () => {
449
+ const result = renderSlotBadges(
450
+ ["{{if(gt(file.count, 1), file.count)}}"],
451
+ { file: { count: 5 }, source: {}, searchMeta: undefined },
452
+ makeCtx(),
453
+ )
454
+ expect(result).toHaveLength(1)
455
+ expect(result[0]).toBe(5)
456
+ })
457
+ })