@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.
- package/LICENSE +18 -0
- package/README.md +59 -0
- package/dist/image-variant.d.ts +90 -0
- package/dist/image-variant.js +115 -0
- package/dist/image-variant.js.map +1 -0
- package/dist/index.d.ts +772 -0
- package/dist/index.js +353 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest-Dk6_xyNy.d.ts +204 -0
- package/dist/media-exts.d.ts +92 -0
- package/dist/media-exts.js +160 -0
- package/dist/media-exts.js.map +1 -0
- package/dist/plugin-asset-limits.d.ts +16 -0
- package/dist/plugin-asset-limits.js +13 -0
- package/dist/plugin-asset-limits.js.map +1 -0
- package/dist/plugin-capabilities.d.ts +64 -0
- package/dist/plugin-capabilities.js +37 -0
- package/dist/plugin-capabilities.js.map +1 -0
- package/dist/plugin.d.ts +49 -0
- package/dist/plugin.js +12 -0
- package/dist/plugin.js.map +1 -0
- package/dist/resource.d.ts +26 -0
- package/dist/resource.js +9 -0
- package/dist/resource.js.map +1 -0
- package/dist/result.d.ts +47 -0
- package/dist/result.js +20 -0
- package/dist/result.js.map +1 -0
- package/dist/schema.d.ts +29 -0
- package/dist/schema.js +124 -0
- package/dist/schema.js.map +1 -0
- package/dist/template.d.ts +67 -0
- package/dist/template.js +137 -0
- package/dist/template.js.map +1 -0
- package/dist/text-limits.d.ts +11 -0
- package/dist/text-limits.js +7 -0
- package/dist/text-limits.js.map +1 -0
- package/package.json +102 -0
- package/src/file-list.ts +14 -0
- package/src/image-variant.test.ts +140 -0
- package/src/image-variant.ts +234 -0
- package/src/index.ts +115 -0
- package/src/manifest.ts +186 -0
- package/src/media-exts.ts +245 -0
- package/src/plugin-asset-limits.ts +23 -0
- package/src/plugin-asset.ts +127 -0
- package/src/plugin-capabilities.ts +91 -0
- package/src/plugin-definition.test.ts +117 -0
- package/src/plugin-definition.ts +902 -0
- package/src/plugin.ts +54 -0
- package/src/read-range.ts +12 -0
- package/src/resource.ts +28 -0
- package/src/result.test.ts +64 -0
- package/src/result.ts +73 -0
- package/src/schema.ts +29 -0
- package/src/template.test.ts +116 -0
- package/src/template.ts +199 -0
- 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
|
+
}
|
package/src/resource.ts
ADDED
|
@@ -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
|
+
})
|
package/src/template.ts
ADDED
|
@@ -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
|