@hoardodile/sdk-types 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +59 -0
  3. package/dist/image-variant.d.ts +90 -0
  4. package/dist/image-variant.js +115 -0
  5. package/dist/image-variant.js.map +1 -0
  6. package/dist/index.d.ts +772 -0
  7. package/dist/index.js +353 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/manifest-Dk6_xyNy.d.ts +204 -0
  10. package/dist/media-exts.d.ts +92 -0
  11. package/dist/media-exts.js +160 -0
  12. package/dist/media-exts.js.map +1 -0
  13. package/dist/plugin-asset-limits.d.ts +16 -0
  14. package/dist/plugin-asset-limits.js +13 -0
  15. package/dist/plugin-asset-limits.js.map +1 -0
  16. package/dist/plugin-capabilities.d.ts +64 -0
  17. package/dist/plugin-capabilities.js +37 -0
  18. package/dist/plugin-capabilities.js.map +1 -0
  19. package/dist/plugin.d.ts +49 -0
  20. package/dist/plugin.js +12 -0
  21. package/dist/plugin.js.map +1 -0
  22. package/dist/resource.d.ts +26 -0
  23. package/dist/resource.js +9 -0
  24. package/dist/resource.js.map +1 -0
  25. package/dist/result.d.ts +47 -0
  26. package/dist/result.js +20 -0
  27. package/dist/result.js.map +1 -0
  28. package/dist/schema.d.ts +29 -0
  29. package/dist/schema.js +124 -0
  30. package/dist/schema.js.map +1 -0
  31. package/dist/template.d.ts +67 -0
  32. package/dist/template.js +137 -0
  33. package/dist/template.js.map +1 -0
  34. package/dist/text-limits.d.ts +11 -0
  35. package/dist/text-limits.js +7 -0
  36. package/dist/text-limits.js.map +1 -0
  37. package/package.json +102 -0
  38. package/src/file-list.ts +14 -0
  39. package/src/image-variant.test.ts +140 -0
  40. package/src/image-variant.ts +234 -0
  41. package/src/index.ts +115 -0
  42. package/src/manifest.ts +186 -0
  43. package/src/media-exts.ts +245 -0
  44. package/src/plugin-asset-limits.ts +23 -0
  45. package/src/plugin-asset.ts +127 -0
  46. package/src/plugin-capabilities.ts +91 -0
  47. package/src/plugin-definition.test.ts +117 -0
  48. package/src/plugin-definition.ts +902 -0
  49. package/src/plugin.ts +54 -0
  50. package/src/read-range.ts +12 -0
  51. package/src/resource.ts +28 -0
  52. package/src/result.test.ts +64 -0
  53. package/src/result.ts +73 -0
  54. package/src/schema.ts +29 -0
  55. package/src/template.test.ts +116 -0
  56. package/src/template.ts +199 -0
  57. package/src/text-limits.ts +11 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Plugin-facing runtime limits: the read cap plugins must respect and
3
+ * the fan-out bounds plugins should stay within when probing. Sandbox
4
+ * tuning (watchdog, timeouts, memory) is app-side and lives in
5
+ * `@hoardodile/host` instead.
6
+ */
7
+
8
+ /**
9
+ * Upper bound for a single `readFile` call, full or ranged. Anything
10
+ * bigger must go through byte ranges (`readFileChunks`) so neither the
11
+ * host process nor the plugin worker buffers it whole.
12
+ */
13
+ export const PLUGIN_READ_FILE_MAX_BYTES = 128 * 1024 * 1024
14
+
15
+ /**
16
+ * Parallel image probes a plugin hook may fan out across the host.
17
+ * Host-side probes run sharp concurrently; keep them bounded.
18
+ */
19
+ export const PLUGIN_IMAGE_PROBE_CONCURRENCY = 8
20
+
21
+ /**
22
+ * Parallel video probes a plugin hook may fan out. Each spawns an
23
+ * ffprobe process host-side, so videos are bound tighter than images.
24
+ */
25
+ export const PLUGIN_VIDEO_PROBE_CONCURRENCY = 4
26
+
27
+ /**
28
+ * Parallel audio probes a plugin hook may fan out. Shares the ffprobe
29
+ * spawn budget with video, so it carries the same bound.
30
+ */
31
+ export const PLUGIN_AUDIO_PROBE_CONCURRENCY = 4
32
+
33
+ /**
34
+ * How many audio files a `coverLocal` hook scans looking for embedded
35
+ * artwork before settling for the first audio file. Albums carry the
36
+ * same artwork on every track, so the scan almost always stops at the
37
+ * first probe; the cap keeps a pathological archive from spawning one
38
+ * ffprobe per track.
39
+ */
40
+ export const PLUGIN_AUDIO_COVER_SCAN_LIMIT = 8
41
+
42
+ /**
43
+ * Chunk size for batch `statFiles` calls: the host resolves each chunk
44
+ * in one RPC round-trip, so a 100-file archive costs ~13 round-trips
45
+ * instead of 100.
46
+ */
47
+ export const PLUGIN_STAT_CONCURRENCY = 8
48
+
49
+ /**
50
+ * Batch size for animation scans in `searchMeta` hooks: probes run
51
+ * concurrently within a batch, and the early-exit check happens between
52
+ * batches.
53
+ */
54
+ export const PLUGIN_ANIMATION_SCAN_BATCH = 8
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Byte range used by every file read across the plugin stack — the
3
+ * server-side `ResourceAPI.readFile` (see `@hoardodile/host`) and the
4
+ * browser-side `PluginRequests.readFile` (see `@hoardodile/sdk-web`) —
5
+ * so the contract lives exactly once. `start` is inclusive (default 0),
6
+ * `end` is exclusive (default end of file). Hosts clamp the range to the
7
+ * file size; a range at or past the end resolves to an empty result.
8
+ */
9
+ export type ReadFileRange = {
10
+ readonly start?: number
11
+ readonly end?: number
12
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Plugin-facing resource caps. The preview policy (`exceedsPreviewThresholds`,
3
+ * which consumes the two caps below) lives with its caller in
4
+ * `@hoardodile/sdk-server/helpers`; the cover cap below is consumed by
5
+ * the app's thumb pipeline, the CLI's workbench renders and the media
6
+ * helpers in `@hoardodile/host`. Character image-area caps stay
7
+ * app-internal in `@hoardodile/shared`.
8
+ */
9
+
10
+ /**
11
+ * Schema version stamped onto every `SearchMeta` payload. Plugins
12
+ * that build search-meta MUST emit this exact value so the host can
13
+ * detect format drift across plugin upgrades.
14
+ */
15
+ export const SEARCH_META_VERSION = 1
16
+
17
+ /** Max pixel area for resource covers; larger images are scaled down. */
18
+ export const RESOURCE_COVER_MAX_AREA = 300_000
19
+
20
+ /** Max pixel area for preview variants served to resource previews. */
21
+ export const RESOURCE_PREVIEW_MAX_AREA = 4_000_000
22
+
23
+ /**
24
+ * Byte-size threshold for preview eligibility. An image whose
25
+ * area is at or below the cap may still qualify for preview when
26
+ * its byte size exceeds this value.
27
+ */
28
+ export const RESOURCE_PREVIEW_SIZE_THRESHOLD = 1_000_000
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import { type Detection, err, isErr, isOk, matchResult, ok } from "./index.ts"
3
+
4
+ describe("ok / err", () => {
5
+ test("ok() yields the bare success marker", () => {
6
+ expect(ok()).toEqual({ ok: true })
7
+ })
8
+
9
+ test("err() yields the bare failure marker", () => {
10
+ expect(err()).toEqual({ ok: false })
11
+ })
12
+
13
+ test("payloads spread onto the marker", () => {
14
+ expect(ok({ start: 0, end: 9 })).toEqual({ ok: true, start: 0, end: 9 })
15
+ expect(err({ code: "bad", message: "no" })).toEqual({
16
+ ok: false,
17
+ code: "bad",
18
+ message: "no",
19
+ })
20
+ })
21
+
22
+ test("the plugin-facing literal stays assignable to the shared types", () => {
23
+ const detected: Detection = { ok: true } as const
24
+ expect(detected).toEqual({ ok: true })
25
+ })
26
+ })
27
+
28
+ describe("guards", () => {
29
+ test("isOk narrows the success payload", () => {
30
+ const result = ok({ start: 4, end: 10 })
31
+ expect(isOk(result)).toBe(true)
32
+ if (isOk(result)) {
33
+ expect(result.start).toBe(4)
34
+ }
35
+ })
36
+
37
+ test("isErr narrows the failure payload", () => {
38
+ const result = err({ code: "bad" })
39
+ expect(isErr(result)).toBe(true)
40
+ if (isErr(result)) {
41
+ expect(result.code).toBe("bad")
42
+ }
43
+ })
44
+ })
45
+
46
+ describe("matchResult", () => {
47
+ test("dispatches to the matching handler with its payload", () => {
48
+ const message = (r: { readonly ok: boolean }) =>
49
+ matchResult(r, {
50
+ ok: (p) => `ok ${p.ok}`,
51
+ err: (p) => `err ${p.ok}`,
52
+ })
53
+ expect(message(ok())).toBe("ok true")
54
+ expect(message(err())).toBe("err false")
55
+ })
56
+
57
+ test("handlers can produce a common value", () => {
58
+ const value = matchResult(ok({ start: 1 }), {
59
+ ok: (p) => p.start,
60
+ err: () => 0,
61
+ })
62
+ expect(value).toBe(1)
63
+ })
64
+ })
package/src/result.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared `ok: true/false` result vocabulary (Rust's `Result` in spirit,
3
+ * spread payloads in shape): every site that answers "did it work?" —
4
+ * detections, parses, validations, benchmark runs — uses one type
5
+ * family, one pair of constructors and one pair of guards instead of
6
+ * hand-rolling its own union.
7
+ *
8
+ * The payloads are spread onto the marker rather than carried in a
9
+ * `value`/`error` channel: `ok({ start, end })` is literally
10
+ * `{ ok: true, start, end }`. This keeps every existing consumer's
11
+ * field access (`r.start`, `r.code`, `r.failure`) and every `toEqual`
12
+ * assertion working unchanged, and lets the plugin-facing `{ ok: true }`
13
+ * literal stay the contract.
14
+ */
15
+ export type Ok<TPayload extends object = object> = {
16
+ readonly ok: true
17
+ } & TPayload
18
+ export type Err<TPayload extends object = object> = {
19
+ readonly ok: false
20
+ } & TPayload
21
+ export type Result<TOk extends object = object, TErr extends object = object> =
22
+ | Ok<TOk>
23
+ | Err<TErr>
24
+
25
+ /**
26
+ * Build the success variant; `ok()` alone yields `{ ok: true }`. The
27
+ * cast is the constructor boundary: the runtime value is exactly
28
+ * `{ ok: true, ...payload }`, which the generic spread cannot prove.
29
+ */
30
+ export function ok<TPayload extends object = object>(
31
+ payload?: TPayload,
32
+ ): Ok<TPayload> {
33
+ return { ok: true, ...payload } as Ok<TPayload>
34
+ }
35
+
36
+ /**
37
+ * Build the failure variant; `err()` alone yields `{ ok: false }`. See
38
+ * {@link ok} for the constructor-boundary cast.
39
+ */
40
+ export function err<TPayload extends object = object>(
41
+ payload?: TPayload,
42
+ ): Err<TPayload> {
43
+ return { ok: false, ...payload } as Err<TPayload>
44
+ }
45
+
46
+ /** Narrow a result to its success variant. */
47
+ export function isOk<TOk extends object, TErr extends object>(
48
+ result: Result<TOk, TErr>,
49
+ ): result is Ok<TOk> {
50
+ return result.ok === true
51
+ }
52
+
53
+ /** Narrow a result to its failure variant. */
54
+ export function isErr<TOk extends object, TErr extends object>(
55
+ result: Result<TOk, TErr>,
56
+ ): result is Err<TErr> {
57
+ return result.ok === false
58
+ }
59
+
60
+ /**
61
+ * Destructure a result through one of two handlers — the pattern-match
62
+ * combinator. Both handlers must produce `R`; the chosen one receives
63
+ * the spread payload of its variant.
64
+ */
65
+ export function matchResult<TOk extends object, TErr extends object, R>(
66
+ result: Result<TOk, TErr>,
67
+ handlers: {
68
+ readonly ok: (payload: Ok<TOk>) => R
69
+ readonly err: (payload: Err<TErr>) => R
70
+ },
71
+ ): R {
72
+ return isOk(result) ? handlers.ok(result) : handlers.err(result)
73
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The zod schema layer of the plugin contract: the manifest schema and
3
+ * the wire anchor envelope. Import this subpath
4
+ * (`@hoardodile/sdk-types/schema`) only where a runtime validator is
5
+ * actually needed — the host, the server, and tooling. The root entry
6
+ * re-exports the inferred types only, so plugin bundles never pull zod.
7
+ */
8
+ import { z } from "zod"
9
+
10
+ export * from "./manifest.ts"
11
+
12
+ /**
13
+ * Wire/storage envelope for a message or danmaku anchor. Carries only
14
+ * the plugin-defined location payload in `data`; the host never
15
+ * interprets its contents. The anchor's resource is host state — the SDK
16
+ * injects it from the iframe's binding and the server derives it from
17
+ * the row's `anchor_resource_id` column — so plugins never see a resId
18
+ * here, and a plugin that sends one is rejected (strict).
19
+ *
20
+ * Plugin code works with the raw location data (`PluginSchema["anchor"]`)
21
+ * directly; the SDK wraps it into this envelope when it crosses the
22
+ * wire.
23
+ */
24
+ export const anchorData = z
25
+ .object({
26
+ data: z.unknown().optional(),
27
+ })
28
+ .strict()
29
+ export type AnchorData = z.infer<typeof anchorData>
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from "vitest"
2
+ import {
3
+ parseTemplateExpression,
4
+ parseTemplateFragments,
5
+ tokeniseExpression,
6
+ } from "./template.ts"
7
+
8
+ describe("parseTemplateFragments", () => {
9
+ test("splits text and expressions, keeping the {{ }} contents", () => {
10
+ expect(parseTemplateFragments("P{{file.count}}Q{{t('x')}}")).toEqual([
11
+ { kind: "text", value: "P" },
12
+ { kind: "expr", source: "file.count" },
13
+ { kind: "text", value: "Q" },
14
+ { kind: "expr", source: "t('x')" },
15
+ ])
16
+ })
17
+
18
+ test("handles pure text and unbalanced fragments gracefully", () => {
19
+ expect(parseTemplateFragments("plain")).toEqual([
20
+ { kind: "text", value: "plain" },
21
+ ])
22
+ expect(parseTemplateFragments("{{unclosed")).toEqual([
23
+ { kind: "text", value: "{{unclosed" },
24
+ ])
25
+ })
26
+ })
27
+
28
+ describe("tokeniseExpression", () => {
29
+ test("emits the full token vocabulary", () => {
30
+ const tokens = tokeniseExpression("join(' ', a.b, gt(x, 1))")
31
+ expect(tokens.map((t) => t.kind)).toEqual([
32
+ "ident",
33
+ "lparen",
34
+ "string",
35
+ "comma",
36
+ "ident",
37
+ "dot",
38
+ "ident",
39
+ "comma",
40
+ "ident",
41
+ "lparen",
42
+ "ident",
43
+ "comma",
44
+ "ident",
45
+ "rparen",
46
+ "rparen",
47
+ "eof",
48
+ ])
49
+ })
50
+ })
51
+
52
+ describe("parseTemplateExpression", () => {
53
+ test("parses paths", () => {
54
+ expect(parseTemplateExpression("file.count")).toEqual({
55
+ kind: "path",
56
+ segments: ["file", "count"],
57
+ })
58
+ })
59
+
60
+ test("parses calls with nested args", () => {
61
+ expect(parseTemplateExpression("if(gt(file.count, 1), t('x'))")).toEqual({
62
+ kind: "call",
63
+ name: "if",
64
+ args: [
65
+ {
66
+ kind: "expr",
67
+ expr: {
68
+ kind: "call",
69
+ name: "gt",
70
+ args: [
71
+ {
72
+ kind: "expr",
73
+ expr: { kind: "path", segments: ["file", "count"] },
74
+ },
75
+ { kind: "expr", expr: { kind: "path", segments: ["1"] } },
76
+ ],
77
+ },
78
+ },
79
+ {
80
+ kind: "expr",
81
+ expr: {
82
+ kind: "call",
83
+ name: "t",
84
+ args: [{ kind: "string", value: "x" }],
85
+ },
86
+ },
87
+ ],
88
+ })
89
+ })
90
+
91
+ test("recovers from a missing closing paren with a partial AST", () => {
92
+ expect(parseTemplateExpression("if(gt(a, b")).toEqual({
93
+ kind: "call",
94
+ name: "if",
95
+ args: [
96
+ {
97
+ kind: "expr",
98
+ expr: {
99
+ kind: "call",
100
+ name: "gt",
101
+ args: [
102
+ { kind: "expr", expr: { kind: "path", segments: ["a"] } },
103
+ { kind: "expr", expr: { kind: "path", segments: ["b"] } },
104
+ ],
105
+ },
106
+ },
107
+ ],
108
+ })
109
+ })
110
+
111
+ test("fails on non-identifier heads", () => {
112
+ expect(parseTemplateExpression("(x)")).toBeUndefined()
113
+ expect(parseTemplateExpression("'str'")).toBeUndefined()
114
+ expect(parseTemplateExpression("")).toBeUndefined()
115
+ })
116
+ })
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The host's cover/message template grammar: fragment splitting, the
3
+ * expression tokeniser and the recursive-descent parser. Pure logic
4
+ * with no DOM or React — shared verbatim by the web renderer
5
+ * (`apps/web/src/features/res/template/render.ts`) and the CLI's
6
+ * build-time template lint (`packages/cli`), so the linter can never
7
+ * drift from what the engine actually renders.
8
+ *
9
+ * The parser is deliberately lenient: a partial parse recovers and the
10
+ * evaluator renders what it can (bad expressions render as the empty
11
+ * string). Strictness belongs to the lint tooling, which inspects the
12
+ * tokens and the AST on top of this grammar.
13
+ */
14
+
15
+ const TEMPLATE_RE = /\{\{(.*?)\}\}/g
16
+
17
+ export type TemplateFragment =
18
+ | { readonly kind: "text"; readonly value: string }
19
+ | { readonly kind: "expr"; readonly source: string }
20
+
21
+ /** Split a template into literal text and `{{...}}` expression fragments. */
22
+ export function parseTemplateFragments(
23
+ template: string,
24
+ ): readonly TemplateFragment[] {
25
+ const fragments: TemplateFragment[] = []
26
+ let lastIndex = 0
27
+ for (const match of template.matchAll(TEMPLATE_RE)) {
28
+ const start = match.index ?? 0
29
+ if (start > lastIndex) {
30
+ fragments.push({ kind: "text", value: template.slice(lastIndex, start) })
31
+ }
32
+ fragments.push({ kind: "expr", source: match[1] ?? "" })
33
+ lastIndex = start + match[0].length
34
+ }
35
+ if (lastIndex < template.length) {
36
+ fragments.push({ kind: "text", value: template.slice(lastIndex) })
37
+ }
38
+ return fragments
39
+ }
40
+
41
+ export type TemplateToken =
42
+ | { readonly kind: "ident"; readonly value: string }
43
+ | { readonly kind: "dot" }
44
+ | { readonly kind: "lparen" }
45
+ | { readonly kind: "rparen" }
46
+ | { readonly kind: "comma" }
47
+ | { readonly kind: "string"; readonly value: string }
48
+ | { readonly kind: "eof" }
49
+
50
+ /** Tokenise one expression body (the inside of `{{...}}`). */
51
+ export function tokeniseExpression(source: string): TemplateToken[] {
52
+ const tokens: TemplateToken[] = []
53
+ let i = 0
54
+ while (i < source.length) {
55
+ const ch = source[i]!
56
+ if (/\s/.test(ch)) {
57
+ i++
58
+ continue
59
+ }
60
+ if (ch === ".") {
61
+ tokens.push({ kind: "dot" })
62
+ i++
63
+ continue
64
+ }
65
+ if (ch === "(") {
66
+ tokens.push({ kind: "lparen" })
67
+ i++
68
+ continue
69
+ }
70
+ if (ch === ")") {
71
+ tokens.push({ kind: "rparen" })
72
+ i++
73
+ continue
74
+ }
75
+ if (ch === ",") {
76
+ tokens.push({ kind: "comma" })
77
+ i++
78
+ continue
79
+ }
80
+ if (ch === "'") {
81
+ let j = i + 1
82
+ while (j < source.length && source[j] !== "'") {
83
+ j++
84
+ }
85
+ tokens.push({ kind: "string", value: source.slice(i + 1, j) })
86
+ i = j + 1
87
+ continue
88
+ }
89
+ if (/[A-Za-z0-9_]/.test(ch)) {
90
+ let j = i
91
+ while (j < source.length && /[A-Za-z0-9_]/.test(source[j]!)) {
92
+ j++
93
+ }
94
+ tokens.push({ kind: "ident", value: source.slice(i, j) })
95
+ i = j
96
+ continue
97
+ }
98
+ // Unrecognised character — skip; the evaluator renders the
99
+ // resulting expression as the empty string.
100
+ i++
101
+ }
102
+ tokens.push({ kind: "eof" })
103
+ return tokens
104
+ }
105
+
106
+ export type TemplateExpr =
107
+ | { readonly kind: "path"; readonly segments: readonly string[] }
108
+ | {
109
+ readonly kind: "call"
110
+ readonly name: string
111
+ readonly args: readonly TemplateArg[]
112
+ }
113
+
114
+ export type TemplateArg =
115
+ | { readonly kind: "expr"; readonly expr: TemplateExpr }
116
+ | { readonly kind: "string"; readonly value: string }
117
+
118
+ class Parser {
119
+ readonly tokens: TemplateToken[]
120
+ pos = 0
121
+ constructor(tokens: TemplateToken[]) {
122
+ this.tokens = tokens
123
+ }
124
+
125
+ peek(): TemplateToken {
126
+ return this.tokens[this.pos] ?? { kind: "eof" }
127
+ }
128
+
129
+ advance(): TemplateToken {
130
+ const t = this.tokens[this.pos]
131
+ this.pos++
132
+ return t ?? { kind: "eof" }
133
+ }
134
+ }
135
+
136
+ function parseExpr(parser: Parser): TemplateExpr | undefined {
137
+ const t = parser.peek()
138
+ if (t.kind !== "ident") return undefined
139
+ parser.advance()
140
+
141
+ const next = parser.peek()
142
+ if (next.kind === "lparen") {
143
+ // call
144
+ parser.advance() // consume (
145
+ const args: TemplateArg[] = []
146
+ if (parser.peek().kind !== "rparen") {
147
+ while (true) {
148
+ const arg = parseArg(parser)
149
+ if (arg === undefined) break
150
+ args.push(arg)
151
+ if (parser.peek().kind === "comma") {
152
+ parser.advance()
153
+ continue
154
+ }
155
+ break
156
+ }
157
+ }
158
+ if (parser.peek().kind === "rparen") {
159
+ parser.advance()
160
+ }
161
+ return { kind: "call", name: t.value, args }
162
+ }
163
+
164
+ // path
165
+ const segments = [t.value]
166
+ while (parser.peek().kind === "dot") {
167
+ parser.advance()
168
+ const seg = parser.peek()
169
+ if (seg.kind !== "ident") break
170
+ parser.advance()
171
+ segments.push(seg.value)
172
+ }
173
+ return { kind: "path", segments }
174
+ }
175
+
176
+ function parseArg(parser: Parser): TemplateArg | undefined {
177
+ const t = parser.peek()
178
+ if (t.kind === "string") {
179
+ parser.advance()
180
+ return { kind: "string", value: t.value }
181
+ }
182
+ const expr = parseExpr(parser)
183
+ if (expr === undefined) return undefined
184
+ return { kind: "expr", expr }
185
+ }
186
+
187
+ /**
188
+ * Parse one expression body (`{{...}}` contents) into an AST. Returns
189
+ * `undefined` when the expression does not start with an identifier —
190
+ * a call or path head is required. Note the parser is lenient about
191
+ * the *tail*: unbalanced parentheses recover into a partial AST (the
192
+ * evaluator renders what it can); use {@link tokeniseExpression} and
193
+ * check paren balance yourself when strictness matters.
194
+ */
195
+ export function parseTemplateExpression(
196
+ source: string,
197
+ ): TemplateExpr | undefined {
198
+ return parseExpr(new Parser(tokeniseExpression(source)))
199
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Plugin-facing text-length limits: the input caps plugins that render
3
+ * composers need. App-side field limits live in
4
+ * `@hoardodile/schemas/text-limits` instead.
5
+ */
6
+
7
+ /** Danmaku body cap enforced by the host. */
8
+ export const MAX_DANMAKU_TEXT_LENGTH = 100
9
+
10
+ /** Comment/message body cap enforced by the host. */
11
+ export const MAX_COMMENT_BODY_LENGTH = 10_000