@hoardodile/ui 0.1.5 → 0.1.7

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 (55) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/breadcrumb.js +3 -0
  3. package/dist/components/breadcrumb.js.map +1 -1
  4. package/dist/components/caption-bar.js +3 -0
  5. package/dist/components/caption-bar.js.map +1 -1
  6. package/dist/components/color-picker.js +3 -0
  7. package/dist/components/color-picker.js.map +1 -1
  8. package/dist/components/combobox.js +3 -0
  9. package/dist/components/combobox.js.map +1 -1
  10. package/dist/components/dropdown-menu.js +3 -0
  11. package/dist/components/dropdown-menu.js.map +1 -1
  12. package/dist/components/dropdown-select.js +3 -0
  13. package/dist/components/dropdown-select.js.map +1 -1
  14. package/dist/components/font-picker.js +3 -0
  15. package/dist/components/font-picker.js.map +1 -1
  16. package/dist/components/image-crop-panel.js +3 -0
  17. package/dist/components/image-crop-panel.js.map +1 -1
  18. package/dist/components/pagination-bar.js +3 -0
  19. package/dist/components/pagination-bar.js.map +1 -1
  20. package/dist/components/pagination.js +3 -0
  21. package/dist/components/pagination.js.map +1 -1
  22. package/dist/components/panel-toolbar.js +3 -0
  23. package/dist/components/panel-toolbar.js.map +1 -1
  24. package/dist/components/search-field.js +3 -0
  25. package/dist/components/search-field.js.map +1 -1
  26. package/dist/components/sidebar.js +3 -0
  27. package/dist/components/sidebar.js.map +1 -1
  28. package/dist/components/toast.js +3 -0
  29. package/dist/components/toast.js.map +1 -1
  30. package/dist/icons/actions.js +3 -0
  31. package/dist/icons/actions.js.map +1 -1
  32. package/dist/icons/registry.d.ts +2 -1
  33. package/dist/icons/registry.js +9 -1
  34. package/dist/icons/registry.js.map +1 -1
  35. package/dist/res-card-template/format.d.ts +21 -0
  36. package/dist/res-card-template/format.js +23 -0
  37. package/dist/res-card-template/format.js.map +1 -0
  38. package/dist/res-card-template/icon.d.ts +38 -0
  39. package/dist/res-card-template/icon.js +41 -0
  40. package/dist/res-card-template/icon.js.map +1 -0
  41. package/dist/res-card-template/index.d.ts +4 -0
  42. package/dist/res-card-template/index.js +331 -0
  43. package/dist/res-card-template/index.js.map +1 -0
  44. package/dist/res-card-template/template.d.ts +48 -0
  45. package/dist/res-card-template/template.js +333 -0
  46. package/dist/res-card-template/template.js.map +1 -0
  47. package/package.json +9 -2
  48. package/src/icons/registry.ts +8 -0
  49. package/src/res-card-template/format.test.ts +45 -0
  50. package/src/res-card-template/format.ts +36 -0
  51. package/src/res-card-template/icon.test.ts +100 -0
  52. package/src/res-card-template/icon.ts +86 -0
  53. package/src/res-card-template/index.ts +22 -0
  54. package/src/res-card-template/template.test.ts +457 -0
  55. package/src/res-card-template/template.ts +429 -0
@@ -0,0 +1,429 @@
1
+ import {
2
+ type TemplateArg as Arg,
3
+ type TemplateExpr as Expr,
4
+ parseTemplateExpression,
5
+ parseTemplateFragments,
6
+ } from "@hoardodile/sdk-types/template"
7
+ import type { ReactNode } from "react"
8
+ import { formatBytes, formatClockDuration } from "./format.ts"
9
+ import { parseIconRef, type RenderIcon } from "./icon.ts"
10
+
11
+ // The template grammar (fragment splitting, tokenising, parsing) lives
12
+ // in @hoardodile/sdk-types/template — shared verbatim with the CLI's
13
+ // build-time template lint so the two can never drift. This module only
14
+ // evaluates the AST. The parser is lenient: unparseable expressions
15
+ // render as the empty string.
16
+
17
+ // ── Evaluation context ───────────────────────────────────────────────────────
18
+
19
+ /** How a referenced icon is drawn; the caller owns the SVG/image resolution. */
20
+ export type TemplateContext = {
21
+ readonly locale: string
22
+ readonly pluginId: string
23
+ readonly manifest: {
24
+ readonly i18n?: Record<string, string | Record<string, string>>
25
+ readonly ui?: {
26
+ readonly search?: {
27
+ readonly kinds?: readonly {
28
+ readonly key: string
29
+ readonly icon?: string
30
+ }[]
31
+ }
32
+ }
33
+ }
34
+ readonly iconClassName?: string
35
+ /** Renders a parsed {@link IconRef} (glyph or asset) inside the template. */
36
+ readonly renderIcon: RenderIcon
37
+ /** Resolves a manifest-relative `asset('path')` reference to a URL. */
38
+ readonly buildAssetUrl: (pluginId: string, path: string) => string
39
+ }
40
+
41
+ type EvalValue = string | ReactNode
42
+
43
+ type TemplateScope = {
44
+ readonly file: unknown
45
+ readonly source: unknown
46
+ readonly searchMeta?: unknown
47
+ readonly data?: unknown
48
+ readonly coverMeta?: unknown
49
+ }
50
+
51
+ // ── Scope resolution (paths) ─────────────────────────────────────────────────
52
+
53
+ function resolvePath(
54
+ segments: readonly string[],
55
+ scope: TemplateScope,
56
+ ): unknown {
57
+ const [namespace, ...rest] = segments
58
+ const root =
59
+ namespace === "file"
60
+ ? scope.file
61
+ : namespace === "searchMeta"
62
+ ? scope.searchMeta
63
+ : namespace === "data"
64
+ ? scope.data
65
+ : namespace === "coverMeta"
66
+ ? scope.coverMeta
67
+ : scope.source
68
+ if (root === undefined || root === null) return undefined
69
+ let current: unknown = root
70
+ for (const key of rest) {
71
+ if (typeof current !== "object" || current === null) return undefined
72
+ current = (current as Record<string, unknown>)[key]
73
+ }
74
+ return current
75
+ }
76
+
77
+ // ── Locale resolution ────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Resolve a locale-aware template value: if the value is a plain string,
81
+ * return it directly; if it is a `Record<locale, string>`, pick the best
82
+ * match for the current language. Falls back to the first available key
83
+ * when nothing matches.
84
+ */
85
+ export function resolveLocaleString(
86
+ value: string | Record<string, string>,
87
+ locale: string,
88
+ ): string {
89
+ if (typeof value === "string") return value
90
+ const exact = value[locale]
91
+ if (exact !== undefined) return exact
92
+ const base = locale.split("-")[0] ?? locale
93
+ const partial = value[base]
94
+ if (partial !== undefined) return partial
95
+ const first = Object.values(value)[0]
96
+ return first ?? ""
97
+ }
98
+
99
+ // ── Expression evaluation ────────────────────────────────────────────────────
100
+
101
+ function evaluateExpression(
102
+ expr: Expr,
103
+ scope: TemplateScope,
104
+ ctx: TemplateContext,
105
+ ): EvalValue {
106
+ if (expr.kind === "path") {
107
+ const value = resolvePath(expr.segments, scope)
108
+ if (value === undefined || value === null) return ""
109
+ return String(value)
110
+ }
111
+ return evaluateCall(expr.name, expr.args, scope, ctx)
112
+ }
113
+
114
+ function evaluateCall(
115
+ name: string,
116
+ args: readonly Arg[],
117
+ scope: TemplateScope,
118
+ ctx: TemplateContext,
119
+ ): EvalValue {
120
+ switch (name) {
121
+ case "bytes":
122
+ return callPipe(args, scope, ctx, (value) =>
123
+ typeof value === "number" ? formatBytes(value) : "",
124
+ )
125
+ case "duration":
126
+ return callPipe(args, scope, ctx, (value) =>
127
+ typeof value === "number" ? formatClockDuration(value) : "",
128
+ )
129
+ case "number":
130
+ return callPipe(args, scope, ctx, (value) =>
131
+ typeof value === "number" && Number.isFinite(value)
132
+ ? value.toLocaleString()
133
+ : "",
134
+ )
135
+ case "inc":
136
+ return callPipe(args, scope, ctx, (value) =>
137
+ typeof value === "number" && Number.isFinite(value)
138
+ ? String(value + 1)
139
+ : "",
140
+ )
141
+ case "eq":
142
+ return compareCall(args, scope, ctx, (a, b) => a === b)
143
+ case "ne":
144
+ return compareCall(args, scope, ctx, (a, b) => a !== b)
145
+ case "gt":
146
+ return compareCall(args, scope, ctx, (a, b) => {
147
+ if (typeof a !== "number" || typeof b !== "number") return false
148
+ return a > b
149
+ })
150
+ case "lt":
151
+ return compareCall(args, scope, ctx, (a, b) => {
152
+ if (typeof a !== "number" || typeof b !== "number") return false
153
+ return a < b
154
+ })
155
+ case "gte":
156
+ return compareCall(args, scope, ctx, (a, b) => {
157
+ if (typeof a !== "number" || typeof b !== "number") return false
158
+ return a >= b
159
+ })
160
+ case "lte":
161
+ return compareCall(args, scope, ctx, (a, b) => {
162
+ if (typeof a !== "number" || typeof b !== "number") return false
163
+ return a <= b
164
+ })
165
+ case "if":
166
+ return callIf(args, scope, ctx)
167
+ case "t":
168
+ return callT(args, ctx)
169
+ case "icon":
170
+ return callIcon(args, ctx)
171
+ case "asset":
172
+ return callAsset(args, ctx)
173
+ case "kind":
174
+ return callKind(args, scope, ctx)
175
+ case "searchKindIcons":
176
+ return callSearchKindIcons(scope, ctx)
177
+ case "join":
178
+ return callJoin(args, scope, ctx)
179
+ default:
180
+ return ""
181
+ }
182
+ }
183
+
184
+ function callPipe(
185
+ args: readonly Arg[],
186
+ scope: TemplateScope,
187
+ ctx: TemplateContext,
188
+ pipeFn: (value: unknown) => string,
189
+ ): string {
190
+ const value = evaluateArgAsPrimitive(args[0], scope, ctx)
191
+ return pipeFn(value)
192
+ }
193
+
194
+ function callT(args: readonly Arg[], ctx: TemplateContext): string {
195
+ const key = evaluateArgAsString(args[0])
196
+ if (key.length === 0) return ""
197
+ const map = ctx.manifest.i18n
198
+ if (map === undefined) return ""
199
+ const value = map[key]
200
+ if (value === undefined) return ""
201
+ return resolveLocaleString(value, ctx.locale)
202
+ }
203
+
204
+ function callIcon(args: readonly Arg[], ctx: TemplateContext): ReactNode {
205
+ const name = evaluateArgAsString(args[0])
206
+ if (name.length === 0) return null
207
+ return ctx.renderIcon({ kind: "icon", name }, ctx.iconClassName)
208
+ }
209
+
210
+ function callAsset(args: readonly Arg[], ctx: TemplateContext): ReactNode {
211
+ const path = evaluateArgAsString(args[0])
212
+ if (path.length === 0) return null
213
+ const ref = parseIconRef(path, ctx.pluginId, ctx.buildAssetUrl)
214
+ if (ref === undefined) return null
215
+ return ctx.renderIcon(ref, ctx.iconClassName)
216
+ }
217
+
218
+ function callKind(
219
+ args: readonly Arg[],
220
+ scope: TemplateScope,
221
+ ctx: TemplateContext,
222
+ ): ReactNode {
223
+ const key = evaluateArgAsString(args[0])
224
+ if (key.length === 0) return null
225
+ const kinds = ctx.manifest.ui?.search?.kinds
226
+ if (kinds === undefined) return null
227
+ const match = kinds.find((k) => k.key === key)
228
+ if (match === undefined || match.icon === undefined) return null
229
+ const rendered = renderCardTemplate(match.icon, scope, ctx)
230
+ if (rendered === null || rendered === undefined || rendered === "")
231
+ return null
232
+ return rendered
233
+ }
234
+
235
+ function callSearchKindIcons(
236
+ scope: TemplateScope,
237
+ ctx: TemplateContext,
238
+ ): readonly ReactNode[] {
239
+ if (
240
+ scope.searchMeta === undefined ||
241
+ scope.searchMeta === null ||
242
+ typeof scope.searchMeta !== "object"
243
+ )
244
+ return []
245
+ const facets = (scope.searchMeta as Record<string, unknown>).facets
246
+ if (facets === undefined || typeof facets !== "object" || facets === null)
247
+ return []
248
+ const kinds = ctx.manifest.ui?.search?.kinds
249
+ if (kinds === undefined) return []
250
+ const results: ReactNode[] = []
251
+ for (const kind of kinds) {
252
+ if (kind.icon === undefined) continue
253
+ const active = (facets as Record<string, boolean>)[kind.key]
254
+ if (active !== true) continue
255
+ const rendered = renderCardTemplate(kind.icon, scope, ctx)
256
+ if (rendered === null || rendered === undefined || rendered === "") continue
257
+ results.push(rendered)
258
+ }
259
+ return results
260
+ }
261
+
262
+ function callJoin(
263
+ args: readonly Arg[],
264
+ scope: TemplateScope,
265
+ ctx: TemplateContext,
266
+ ): string | readonly ReactNode[] {
267
+ if (args.length === 0) return ""
268
+
269
+ const separator = evaluateArgAsString(args[0])
270
+ const items: ReactNode[] = []
271
+ for (let i = 1; i < args.length; i++) {
272
+ const val = evaluateArg(args[i], scope, ctx)
273
+ if (Array.isArray(val)) {
274
+ for (const item of val) {
275
+ if (item !== null && item !== undefined && item !== "") {
276
+ items.push(item)
277
+ }
278
+ }
279
+ continue
280
+ }
281
+ if (val !== null && val !== undefined && val !== "") {
282
+ items.push(val)
283
+ }
284
+ }
285
+ if (items.length === 0) return ""
286
+ if (items.every((item) => typeof item === "string")) {
287
+ return (items as readonly string[]).join(separator)
288
+ }
289
+ if (separator.length === 0) return items
290
+ const interleaved: ReactNode[] = []
291
+ for (let i = 0; i < items.length; i++) {
292
+ if (i > 0) interleaved.push(separator)
293
+ interleaved.push(items[i])
294
+ }
295
+ return interleaved
296
+ }
297
+
298
+ // ── Argument helpers ─────────────────────────────────────────────────────────
299
+
300
+ function evaluateArgAsPrimitive(
301
+ arg: Arg | undefined,
302
+ scope: TemplateScope,
303
+ ctx: TemplateContext,
304
+ ): unknown {
305
+ if (arg === undefined) return undefined
306
+ if (arg.kind === "string") return arg.value
307
+ if (arg.expr.kind === "path") {
308
+ const segments = arg.expr.segments
309
+ if (segments.length === 1) {
310
+ const seg = segments[0]!
311
+ const num = Number(seg)
312
+ if (!Number.isNaN(num)) return num
313
+ }
314
+ return resolvePath(segments, scope)
315
+ }
316
+ if (arg.expr.kind === "call") {
317
+ const result = evaluateCall(arg.expr.name, arg.expr.args, scope, ctx)
318
+ if (typeof result === "string" || typeof result === "number") return result
319
+ if (Array.isArray(result)) return result
320
+ return undefined
321
+ }
322
+ return undefined
323
+ }
324
+
325
+ function evaluateArg(
326
+ arg: Arg | undefined,
327
+ scope: TemplateScope,
328
+ ctx: TemplateContext,
329
+ ): EvalValue {
330
+ if (arg === undefined) return undefined
331
+ if (arg.kind === "string") return arg.value
332
+ return evaluateExpression(arg.expr, scope, ctx)
333
+ }
334
+
335
+ function compareCall(
336
+ args: readonly Arg[],
337
+ scope: TemplateScope,
338
+ ctx: TemplateContext,
339
+ cmp: (a: unknown, b: unknown) => boolean,
340
+ ): string {
341
+ const a = evaluateArgAsPrimitive(args[0], scope, ctx)
342
+ const b = evaluateArgAsPrimitive(args[1], scope, ctx)
343
+ return cmp(a, b) ? "true" : ""
344
+ }
345
+
346
+ function callIf(
347
+ args: readonly Arg[],
348
+ scope: TemplateScope,
349
+ ctx: TemplateContext,
350
+ ): EvalValue {
351
+ const cond = evaluateArgAsPrimitive(args[0], scope, ctx)
352
+ const truthy =
353
+ cond !== undefined &&
354
+ cond !== null &&
355
+ cond !== "" &&
356
+ cond !== false &&
357
+ cond !== 0
358
+ if (truthy) {
359
+ const raw = evaluateArgAsPrimitive(args[1], scope, ctx)
360
+ if (raw !== undefined) return raw as EvalValue
361
+ return evaluateArg(args[1], scope, ctx)
362
+ }
363
+ if (args.length > 2) {
364
+ const raw = evaluateArgAsPrimitive(args[2], scope, ctx)
365
+ if (raw !== undefined) return raw as EvalValue
366
+ return evaluateArg(args[2], scope, ctx)
367
+ }
368
+ return ""
369
+ }
370
+
371
+ function evaluateArgAsString(arg: Arg | undefined): string {
372
+ if (arg === undefined) return ""
373
+ if (arg.kind === "string") return arg.value
374
+ return ""
375
+ }
376
+
377
+ // ── Public API ───────────────────────────────────────────────────────────────
378
+
379
+ /**
380
+ * Render one res-card template string. Returns a ReactNode so that
381
+ * icon-producing functions (`icon`, `asset`, `kind`) can inject
382
+ * components inline with text.
383
+ */
384
+ export function renderCardTemplate(
385
+ template: string,
386
+ scope: TemplateScope,
387
+ ctx: TemplateContext,
388
+ ): ReactNode {
389
+ if (template.length === 0) return ""
390
+ const fragments = parseTemplateFragments(template)
391
+ if (fragments.length === 0) return ""
392
+
393
+ const results: ReactNode[] = []
394
+ for (const frag of fragments) {
395
+ if (frag.kind === "text") {
396
+ results.push(frag.value)
397
+ continue
398
+ }
399
+ const expr = parseTemplateExpression(frag.source)
400
+ if (expr === undefined) {
401
+ continue
402
+ }
403
+ const value = evaluateExpression(expr, scope, ctx)
404
+ if (value !== null && value !== undefined && value !== "") {
405
+ results.push(value)
406
+ }
407
+ }
408
+
409
+ if (results.length === 0) return null
410
+ if (results.length === 1) return results[0]
411
+ const allStrings = results.every((r) => typeof r === "string")
412
+ if (allStrings) return results.join("")
413
+ return results
414
+ }
415
+
416
+ /** Render every badge in a {@link CoverKindUi} slot array. */
417
+ export function renderSlotBadges(
418
+ slotValues: readonly string[],
419
+ scope: TemplateScope,
420
+ ctx: TemplateContext,
421
+ ): readonly ReactNode[] {
422
+ return slotValues
423
+ .map((template) => renderCardTemplate(template, scope, ctx))
424
+ .filter((n): n is Exclude<typeof n, null | undefined | ""> => {
425
+ if (n === null || n === undefined || n === "") return false
426
+ if (Array.isArray(n) && n.length === 0) return false
427
+ return true
428
+ })
429
+ }