@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/package.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hoardodile/sdk-types",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Plugin manifest contract, shared types and constants for hoardodile content plugins.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"hoardodile",
|
|
8
|
+
"plugin",
|
|
9
|
+
"sdk",
|
|
10
|
+
"types",
|
|
11
|
+
"manifest",
|
|
12
|
+
"constants"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/hoardodile/hoardodile.git",
|
|
17
|
+
"directory": "plugins/sdk-types"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"development": "./src/index.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./image-variant": {
|
|
27
|
+
"types": "./dist/image-variant.d.ts",
|
|
28
|
+
"development": "./src/image-variant.ts",
|
|
29
|
+
"default": "./dist/image-variant.js"
|
|
30
|
+
},
|
|
31
|
+
"./media-exts": {
|
|
32
|
+
"types": "./dist/media-exts.d.ts",
|
|
33
|
+
"development": "./src/media-exts.ts",
|
|
34
|
+
"default": "./dist/media-exts.js"
|
|
35
|
+
},
|
|
36
|
+
"./plugin": {
|
|
37
|
+
"types": "./dist/plugin.d.ts",
|
|
38
|
+
"development": "./src/plugin.ts",
|
|
39
|
+
"default": "./dist/plugin.js"
|
|
40
|
+
},
|
|
41
|
+
"./plugin-asset-limits": {
|
|
42
|
+
"types": "./dist/plugin-asset-limits.d.ts",
|
|
43
|
+
"development": "./src/plugin-asset-limits.ts",
|
|
44
|
+
"default": "./dist/plugin-asset-limits.js"
|
|
45
|
+
},
|
|
46
|
+
"./plugin-capabilities": {
|
|
47
|
+
"types": "./dist/plugin-capabilities.d.ts",
|
|
48
|
+
"development": "./src/plugin-capabilities.ts",
|
|
49
|
+
"default": "./dist/plugin-capabilities.js"
|
|
50
|
+
},
|
|
51
|
+
"./resource": {
|
|
52
|
+
"types": "./dist/resource.d.ts",
|
|
53
|
+
"development": "./src/resource.ts",
|
|
54
|
+
"default": "./dist/resource.js"
|
|
55
|
+
},
|
|
56
|
+
"./result": {
|
|
57
|
+
"types": "./dist/result.d.ts",
|
|
58
|
+
"development": "./src/result.ts",
|
|
59
|
+
"default": "./dist/result.js"
|
|
60
|
+
},
|
|
61
|
+
"./schema": {
|
|
62
|
+
"types": "./dist/schema.d.ts",
|
|
63
|
+
"development": "./src/schema.ts",
|
|
64
|
+
"default": "./dist/schema.js"
|
|
65
|
+
},
|
|
66
|
+
"./template": {
|
|
67
|
+
"types": "./dist/template.d.ts",
|
|
68
|
+
"development": "./src/template.ts",
|
|
69
|
+
"default": "./dist/template.js"
|
|
70
|
+
},
|
|
71
|
+
"./text-limits": {
|
|
72
|
+
"types": "./dist/text-limits.d.ts",
|
|
73
|
+
"development": "./src/text-limits.ts",
|
|
74
|
+
"default": "./dist/text-limits.js"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"files": [
|
|
78
|
+
"dist",
|
|
79
|
+
"src"
|
|
80
|
+
],
|
|
81
|
+
"sideEffects": false,
|
|
82
|
+
"engines": {
|
|
83
|
+
"node": ">=24"
|
|
84
|
+
},
|
|
85
|
+
"publishConfig": {
|
|
86
|
+
"access": "public"
|
|
87
|
+
},
|
|
88
|
+
"peerDependencies": {
|
|
89
|
+
"zod": "^4.4.3"
|
|
90
|
+
},
|
|
91
|
+
"devDependencies": {
|
|
92
|
+
"tsup": "^8.5.1",
|
|
93
|
+
"typescript": "5.9.3",
|
|
94
|
+
"vitest": "^4.1.11",
|
|
95
|
+
"zod": "^4.4.3"
|
|
96
|
+
},
|
|
97
|
+
"scripts": {
|
|
98
|
+
"build": "tsup",
|
|
99
|
+
"lint": "tsc --noEmit",
|
|
100
|
+
"test": "vitest run --maxWorkers=2"
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/file-list.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One item in a serialized file list: a bare filename string or a
|
|
3
|
+
* metadata object. Object entries carry `filename` plus any extra
|
|
4
|
+
* fields; the host renders covers and chips from this shape.
|
|
5
|
+
*/
|
|
6
|
+
export type SerializedFileEntry =
|
|
7
|
+
| string
|
|
8
|
+
| Record<string, string | number | boolean>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Serialized file list as stored in the sidecar cache and sent over the
|
|
12
|
+
* wire. Order is the display order; the host preserves it verbatim.
|
|
13
|
+
*/
|
|
14
|
+
export type SerializedFileList = readonly SerializedFileEntry[]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
imageVariantCanonical,
|
|
4
|
+
imageVariantQuery,
|
|
5
|
+
normalizeImageVariantSpec,
|
|
6
|
+
parseImageVariantQuery,
|
|
7
|
+
} from "./image-variant.ts"
|
|
8
|
+
import { RESOURCE_PREVIEW_MAX_AREA } from "./resource.ts"
|
|
9
|
+
|
|
10
|
+
const QUALITY_DEFAULTS = { avifQuality: 65, webpQuality: 90 }
|
|
11
|
+
|
|
12
|
+
describe("parseImageVariantQuery", () => {
|
|
13
|
+
test("no variant parameters means the original", () => {
|
|
14
|
+
expect(parseImageVariantQuery({})).toEqual({ kind: "none" })
|
|
15
|
+
expect(parseImageVariantQuery({ size: "original" })).toEqual({
|
|
16
|
+
kind: "none",
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("size=preview alone requests the default variant", () => {
|
|
21
|
+
expect(parseImageVariantQuery({ size: "preview" })).toEqual({
|
|
22
|
+
kind: "variant",
|
|
23
|
+
spec: {},
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("any variant parameter requests a variant", () => {
|
|
28
|
+
const result = parseImageVariantQuery({
|
|
29
|
+
size: "preview",
|
|
30
|
+
fmt: "webp",
|
|
31
|
+
fit: "exact",
|
|
32
|
+
area: "2000000",
|
|
33
|
+
q: "80",
|
|
34
|
+
})
|
|
35
|
+
expect(result).toEqual({
|
|
36
|
+
kind: "variant",
|
|
37
|
+
spec: { format: "webp", fit: "exact", maxArea: 2000000, quality: 80 },
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("numeric strings are coerced", () => {
|
|
42
|
+
expect(parseImageVariantQuery({ area: "4000000", q: 50 })).toEqual({
|
|
43
|
+
kind: "variant",
|
|
44
|
+
spec: { maxArea: 4000000, quality: 50 },
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("invalid format, fit, area and quality are rejected", () => {
|
|
49
|
+
expect(parseImageVariantQuery({ fmt: "png" })).toMatchObject({
|
|
50
|
+
kind: "invalid",
|
|
51
|
+
})
|
|
52
|
+
expect(parseImageVariantQuery({ fit: "cover" })).toMatchObject({
|
|
53
|
+
kind: "invalid",
|
|
54
|
+
})
|
|
55
|
+
expect(parseImageVariantQuery({ area: "0" })).toMatchObject({
|
|
56
|
+
kind: "invalid",
|
|
57
|
+
})
|
|
58
|
+
expect(parseImageVariantQuery({ area: "1.5" })).toMatchObject({
|
|
59
|
+
kind: "invalid",
|
|
60
|
+
})
|
|
61
|
+
expect(parseImageVariantQuery({ q: "101" })).toMatchObject({
|
|
62
|
+
kind: "invalid",
|
|
63
|
+
})
|
|
64
|
+
expect(parseImageVariantQuery({ q: "abc" })).toMatchObject({
|
|
65
|
+
kind: "invalid",
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("normalizeImageVariantSpec", () => {
|
|
71
|
+
test("empty spec fills every default", () => {
|
|
72
|
+
expect(normalizeImageVariantSpec({}, QUALITY_DEFAULTS)).toEqual({
|
|
73
|
+
format: "avif",
|
|
74
|
+
fit: "inside",
|
|
75
|
+
maxArea: RESOURCE_PREVIEW_MAX_AREA,
|
|
76
|
+
avifQuality: 65,
|
|
77
|
+
webpQuality: 90,
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("quality maps onto both per-format qualities", () => {
|
|
82
|
+
const resolved = normalizeImageVariantSpec(
|
|
83
|
+
{ format: "webp", fit: "exact", quality: 80 },
|
|
84
|
+
QUALITY_DEFAULTS,
|
|
85
|
+
)
|
|
86
|
+
expect(resolved).toEqual({
|
|
87
|
+
format: "webp",
|
|
88
|
+
fit: "exact",
|
|
89
|
+
maxArea: RESOURCE_PREVIEW_MAX_AREA,
|
|
90
|
+
avifQuality: 80,
|
|
91
|
+
webpQuality: 80,
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("out-of-range values are clamped, not rejected", () => {
|
|
96
|
+
const resolved = normalizeImageVariantSpec(
|
|
97
|
+
{ maxArea: 0, quality: 500 },
|
|
98
|
+
QUALITY_DEFAULTS,
|
|
99
|
+
)
|
|
100
|
+
expect(resolved.maxArea).toBe(1)
|
|
101
|
+
expect(resolved.avifQuality).toBe(100)
|
|
102
|
+
expect(resolved.webpQuality).toBe(100)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("imageVariantCanonical", () => {
|
|
107
|
+
test("identical variants share one canonical string", () => {
|
|
108
|
+
const a = normalizeImageVariantSpec(
|
|
109
|
+
{ format: "webp", fit: "exact", quality: 80 },
|
|
110
|
+
QUALITY_DEFAULTS,
|
|
111
|
+
)
|
|
112
|
+
// The explicit area equals the default, so both requests render
|
|
113
|
+
// identically and must share a cache identity.
|
|
114
|
+
const b = normalizeImageVariantSpec(
|
|
115
|
+
{
|
|
116
|
+
format: "webp",
|
|
117
|
+
fit: "exact",
|
|
118
|
+
maxArea: RESOURCE_PREVIEW_MAX_AREA,
|
|
119
|
+
quality: 80,
|
|
120
|
+
},
|
|
121
|
+
QUALITY_DEFAULTS,
|
|
122
|
+
)
|
|
123
|
+
expect(imageVariantCanonical(a)).toBe(imageVariantCanonical(b))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("different qualities diverge", () => {
|
|
127
|
+
const a = normalizeImageVariantSpec({ quality: 80 }, QUALITY_DEFAULTS)
|
|
128
|
+
const b = normalizeImageVariantSpec({ quality: 81 }, QUALITY_DEFAULTS)
|
|
129
|
+
expect(imageVariantCanonical(a)).not.toBe(imageVariantCanonical(b))
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("imageVariantQuery", () => {
|
|
134
|
+
test("always carries size=preview so old servers degrade safely", () => {
|
|
135
|
+
expect(imageVariantQuery({})).toBe("size=preview")
|
|
136
|
+
expect(
|
|
137
|
+
imageVariantQuery({ format: "webp", fit: "exact", quality: 80 }),
|
|
138
|
+
).toBe("size=preview&fmt=webp&fit=exact&q=80")
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image variant contract: how a plugin asks the host to derive an image
|
|
3
|
+
* from a resource file, and how that request travels on the wire.
|
|
4
|
+
*
|
|
5
|
+
* One {@link ImageVariantSpec} has three projections, all derived from
|
|
6
|
+
* this module:
|
|
7
|
+
*
|
|
8
|
+
* - the URL query (`imageVariantQuery`) the iframe client emits,
|
|
9
|
+
* - the resolved render spec (`normalizeImageVariantSpec`) the
|
|
10
|
+
* pipelines execute,
|
|
11
|
+
* - the canonical cache identity (`imageVariantCanonical`) that keys
|
|
12
|
+
* the on-disk caches.
|
|
13
|
+
*
|
|
14
|
+
* The per-format encode qualities are pipeline constants, not part of
|
|
15
|
+
* the wire contract — callers fill them in at the render boundary, so
|
|
16
|
+
* this module stays dependency-free and browser-safe.
|
|
17
|
+
*/
|
|
18
|
+
import { RESOURCE_PREVIEW_MAX_AREA } from "./resource.ts"
|
|
19
|
+
|
|
20
|
+
export const IMAGE_VARIANT_FORMATS = ["avif", "webp"] as const
|
|
21
|
+
export type ImageVariantFormat = (typeof IMAGE_VARIANT_FORMATS)[number]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How the source image is fitted into the variant:
|
|
25
|
+
* - `inside` — downscaled (never upscaled) to fit within `maxArea`;
|
|
26
|
+
* - `exact` — transcode only: output pixels are exactly the source
|
|
27
|
+
* dimensions (needed when downstream code maps coordinates onto the
|
|
28
|
+
* texture, e.g. Live2D models).
|
|
29
|
+
*/
|
|
30
|
+
export const IMAGE_VARIANT_FITS = ["inside", "exact"] as const
|
|
31
|
+
export type ImageVariantFit = (typeof IMAGE_VARIANT_FITS)[number]
|
|
32
|
+
|
|
33
|
+
/** Upper bound for the `area` query param (bounded cache-key space). */
|
|
34
|
+
export const IMAGE_VARIANT_MAX_AREA = 1_000_000_000
|
|
35
|
+
|
|
36
|
+
/** Encode-quality range for the `q` query param. */
|
|
37
|
+
export const IMAGE_VARIANT_MIN_QUALITY = 1
|
|
38
|
+
export const IMAGE_VARIANT_MAX_QUALITY = 100
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Client-declared variant request. Every field is optional; omitted
|
|
42
|
+
* fields fall back to the defaults (`format` avif, `fit` inside,
|
|
43
|
+
* `maxArea` {@link RESOURCE_PREVIEW_MAX_AREA}, per-format quality).
|
|
44
|
+
* `maxArea` only has an effect when `fit` is `inside`; it is carried
|
|
45
|
+
* through (and cached under) regardless so URL identity stays stable.
|
|
46
|
+
*/
|
|
47
|
+
export type ImageVariantSpec = {
|
|
48
|
+
readonly format?: ImageVariantFormat
|
|
49
|
+
readonly fit?: ImageVariantFit
|
|
50
|
+
readonly maxArea?: number
|
|
51
|
+
readonly quality?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A fully resolved variant: every field concrete, including the
|
|
56
|
+
* per-format encode qualities the pipeline consumes. This is the render
|
|
57
|
+
* boundary shape and the input to {@link imageVariantCanonical}.
|
|
58
|
+
*/
|
|
59
|
+
export type ResolvedImageVariant = {
|
|
60
|
+
readonly format: ImageVariantFormat
|
|
61
|
+
readonly fit: ImageVariantFit
|
|
62
|
+
readonly maxArea: number
|
|
63
|
+
readonly webpQuality: number
|
|
64
|
+
readonly avifQuality: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Query parameters accepted by the resource file route. */
|
|
68
|
+
export type ImageVariantQuery = {
|
|
69
|
+
readonly size?: string
|
|
70
|
+
readonly fmt?: string
|
|
71
|
+
readonly fit?: string
|
|
72
|
+
readonly area?: string | number
|
|
73
|
+
readonly q?: string | number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type ImageVariantParseResult =
|
|
77
|
+
| { readonly kind: "none" }
|
|
78
|
+
| { readonly kind: "variant"; readonly spec: ImageVariantSpec }
|
|
79
|
+
| { readonly kind: "invalid"; readonly reason: string }
|
|
80
|
+
|
|
81
|
+
export function isImageVariantFormat(
|
|
82
|
+
value: string,
|
|
83
|
+
): value is ImageVariantFormat {
|
|
84
|
+
return IMAGE_VARIANT_FORMATS.some((format) => format === value)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isImageVariantFit(value: string): value is ImageVariantFit {
|
|
88
|
+
return IMAGE_VARIANT_FITS.some((fit) => fit === value)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Interpret the file route's query. A variant is requested when
|
|
93
|
+
* `size=preview` (the compatibility alias) or any variant parameter is
|
|
94
|
+
* present; otherwise the route serves the original bytes.
|
|
95
|
+
* Returns `invalid` for out-of-range or malformed values.
|
|
96
|
+
*/
|
|
97
|
+
export function parseImageVariantQuery(
|
|
98
|
+
query: ImageVariantQuery,
|
|
99
|
+
): ImageVariantParseResult {
|
|
100
|
+
const requested =
|
|
101
|
+
query.size === "preview" ||
|
|
102
|
+
query.fmt !== undefined ||
|
|
103
|
+
query.fit !== undefined ||
|
|
104
|
+
query.area !== undefined ||
|
|
105
|
+
query.q !== undefined
|
|
106
|
+
if (!requested) return { kind: "none" }
|
|
107
|
+
|
|
108
|
+
let format: ImageVariantFormat | undefined
|
|
109
|
+
let fit: ImageVariantFit | undefined
|
|
110
|
+
let maxArea: number | undefined
|
|
111
|
+
let quality: number | undefined
|
|
112
|
+
if (query.fmt !== undefined) {
|
|
113
|
+
if (!isImageVariantFormat(query.fmt)) {
|
|
114
|
+
return invalidFormat(query.fmt)
|
|
115
|
+
}
|
|
116
|
+
format = query.fmt
|
|
117
|
+
}
|
|
118
|
+
if (query.fit !== undefined) {
|
|
119
|
+
if (!isImageVariantFit(query.fit)) {
|
|
120
|
+
return invalidFit(query.fit)
|
|
121
|
+
}
|
|
122
|
+
fit = query.fit
|
|
123
|
+
}
|
|
124
|
+
if (query.area !== undefined) {
|
|
125
|
+
const area = parseVariantInteger(query.area, IMAGE_VARIANT_MAX_AREA)
|
|
126
|
+
if (area === undefined) return invalidArea(query.area)
|
|
127
|
+
maxArea = area
|
|
128
|
+
}
|
|
129
|
+
if (query.q !== undefined) {
|
|
130
|
+
const q = parseVariantInteger(query.q, IMAGE_VARIANT_MAX_QUALITY)
|
|
131
|
+
if (q === undefined) return invalidQuality(query.q)
|
|
132
|
+
quality = q
|
|
133
|
+
}
|
|
134
|
+
return { kind: "variant", spec: { format, fit, maxArea, quality } }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function invalidFormat(value: string): ImageVariantParseResult {
|
|
138
|
+
return {
|
|
139
|
+
kind: "invalid",
|
|
140
|
+
reason: `invalid format "${value}" (expected ${IMAGE_VARIANT_FORMATS.join(", ")})`,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function invalidFit(value: string): ImageVariantParseResult {
|
|
145
|
+
return {
|
|
146
|
+
kind: "invalid",
|
|
147
|
+
reason: `invalid fit "${value}" (expected ${IMAGE_VARIANT_FITS.join(", ")})`,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function invalidArea(value: unknown): ImageVariantParseResult {
|
|
152
|
+
return {
|
|
153
|
+
kind: "invalid",
|
|
154
|
+
reason: `area must be an integer between 1 and ${IMAGE_VARIANT_MAX_AREA} (got "${String(value)}")`,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function invalidQuality(value: unknown): ImageVariantParseResult {
|
|
159
|
+
return {
|
|
160
|
+
kind: "invalid",
|
|
161
|
+
reason: `quality must be an integer between ${IMAGE_VARIANT_MIN_QUALITY} and ${IMAGE_VARIANT_MAX_QUALITY} (got "${String(value)}")`,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseVariantInteger(
|
|
166
|
+
value: string | number,
|
|
167
|
+
max: number,
|
|
168
|
+
): number | undefined {
|
|
169
|
+
const num = typeof value === "number" ? value : Number(value)
|
|
170
|
+
if (!Number.isInteger(num)) return undefined
|
|
171
|
+
if (num < 1 || num > max) return undefined
|
|
172
|
+
return num
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Fill the request defaults and clamp out-of-range values. `quality` is
|
|
177
|
+
* the single client knob; it maps onto both per-format pipeline
|
|
178
|
+
* qualities, which default to the caller's constants when omitted.
|
|
179
|
+
*/
|
|
180
|
+
export function normalizeImageVariantSpec(
|
|
181
|
+
spec: ImageVariantSpec,
|
|
182
|
+
qualityDefaults: {
|
|
183
|
+
readonly avifQuality: number
|
|
184
|
+
readonly webpQuality: number
|
|
185
|
+
},
|
|
186
|
+
): ResolvedImageVariant {
|
|
187
|
+
return {
|
|
188
|
+
format: spec.format ?? "avif",
|
|
189
|
+
fit: spec.fit ?? "inside",
|
|
190
|
+
maxArea: clampArea(spec.maxArea ?? RESOURCE_PREVIEW_MAX_AREA),
|
|
191
|
+
avifQuality: clampQuality(spec.quality ?? qualityDefaults.avifQuality),
|
|
192
|
+
webpQuality: clampQuality(spec.quality ?? qualityDefaults.webpQuality),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function clampArea(value: number): number {
|
|
197
|
+
return Math.min(Math.max(1, Math.round(value)), IMAGE_VARIANT_MAX_AREA)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function clampQuality(value: number): number {
|
|
201
|
+
return Math.min(
|
|
202
|
+
Math.max(IMAGE_VARIANT_MIN_QUALITY, Math.round(value)),
|
|
203
|
+
IMAGE_VARIANT_MAX_QUALITY,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Stable string identity of a resolved variant — the cache key input.
|
|
209
|
+
* Two requests produce the same identity iff they render identically.
|
|
210
|
+
*/
|
|
211
|
+
export function imageVariantCanonical(variant: ResolvedImageVariant): string {
|
|
212
|
+
return [
|
|
213
|
+
variant.format,
|
|
214
|
+
variant.fit,
|
|
215
|
+
variant.maxArea,
|
|
216
|
+
variant.webpQuality,
|
|
217
|
+
variant.avifQuality,
|
|
218
|
+
].join(":")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Encode a variant request as the file route's query string. Always
|
|
223
|
+
* carries `size=preview` so an older server that does not know the
|
|
224
|
+
* variant parameters still degrades to its default preview instead of
|
|
225
|
+
* serving the original bytes.
|
|
226
|
+
*/
|
|
227
|
+
export function imageVariantQuery(spec: ImageVariantSpec): string {
|
|
228
|
+
const params: string[] = ["size=preview"]
|
|
229
|
+
if (spec.format !== undefined) params.push(`fmt=${spec.format}`)
|
|
230
|
+
if (spec.fit !== undefined) params.push(`fit=${spec.fit}`)
|
|
231
|
+
if (spec.maxArea !== undefined) params.push(`area=${spec.maxArea}`)
|
|
232
|
+
if (spec.quality !== undefined) params.push(`q=${spec.quality}`)
|
|
233
|
+
return params.join("&")
|
|
234
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hoardodile/sdk-types — the plugin contract: manifest schema, the
|
|
3
|
+
* plugin definition contract (`PluginDefinition`/`ResourceAPI`/
|
|
4
|
+
* `definePlugin`/fixtures), and the shared message/danmaku/anchor wire
|
|
5
|
+
* shapes. Single source of truth consumed by every SDK package, the
|
|
6
|
+
* host, and the app; nothing here touches the DOM or node.
|
|
7
|
+
*
|
|
8
|
+
* This package is the contract — plugins normally import it for types
|
|
9
|
+
* (`PluginSchema`, `PluginManifest`, ...) and (via
|
|
10
|
+
* `@hoardodile/sdk-server`) the `definePlugin` factory. The zod
|
|
11
|
+
* runtime validators (`pluginManifest`, `anchorData`) live behind the
|
|
12
|
+
* `@hoardodile/sdk-types/schema` subpath so plugin bundles never pull
|
|
13
|
+
* zod.
|
|
14
|
+
*
|
|
15
|
+
* Plugin-facing constants (data, plus the pure lookups that read it)
|
|
16
|
+
* live in subpaths mirroring their source files — there is no root
|
|
17
|
+
* export for them:
|
|
18
|
+
*
|
|
19
|
+
* - `@hoardodile/sdk-types/image-variant` — derived-image variant
|
|
20
|
+
* contract: spec types, query parsing/encoding, canonical cache
|
|
21
|
+
* identity
|
|
22
|
+
* - `@hoardodile/sdk-types/media-exts` — media-type tables: extension
|
|
23
|
+
* sets, extension ↔ MIME, MIME ↔ media kind
|
|
24
|
+
* - `@hoardodile/sdk-types/plugin` — plugin runtime limits (read cap,
|
|
25
|
+
* probe/stat fan-out bounds)
|
|
26
|
+
* - `@hoardodile/sdk-types/resource` — resource caps (search-meta version,
|
|
27
|
+
* preview eligibility)
|
|
28
|
+
* - `@hoardodile/sdk-types/template` — the host cover/message template
|
|
29
|
+
* grammar (fragment splitter, tokeniser, parser — shared with the
|
|
30
|
+
* web renderer and the CLI's build-time lint)
|
|
31
|
+
* - `@hoardodile/sdk-types/text-limits` — plugin input limits (danmaku
|
|
32
|
+
* body, comment body)
|
|
33
|
+
*
|
|
34
|
+
* App-only constants live in `@hoardodile/shared` (infra limits) and
|
|
35
|
+
* `@hoardodile/schemas` (field lengths) instead.
|
|
36
|
+
*/
|
|
37
|
+
export * from "./file-list.ts"
|
|
38
|
+
export type {
|
|
39
|
+
CoverKindUi,
|
|
40
|
+
CoverKindUiMap,
|
|
41
|
+
PluginManifest,
|
|
42
|
+
PluginManifestId,
|
|
43
|
+
PluginManifestUi,
|
|
44
|
+
PluginPermissions,
|
|
45
|
+
SearchKind,
|
|
46
|
+
} from "./manifest.ts"
|
|
47
|
+
export * from "./plugin-asset.ts"
|
|
48
|
+
export * from "./plugin-definition.ts"
|
|
49
|
+
export * from "./read-range.ts"
|
|
50
|
+
export * from "./result.ts"
|
|
51
|
+
export type { AnchorData } from "./schema.ts"
|
|
52
|
+
|
|
53
|
+
/** Web plugin danmaku mode. */
|
|
54
|
+
export type DanmakuMode = "scroll" | "top" | "bottom"
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Client-side danmaku list filter: every entry is matched by strict
|
|
58
|
+
* equality against the same key in the danmaku's anchor `data`. Keys are
|
|
59
|
+
* plugin-defined vocabulary (e.g. `{ kind: "videoTime", filename }` in
|
|
60
|
+
* an official content plugin) — the SDK only defines the matching semantics,
|
|
61
|
+
* not which keys exist.
|
|
62
|
+
*/
|
|
63
|
+
export type DanmakuListFilter = Readonly<
|
|
64
|
+
Record<string, string | number | boolean>
|
|
65
|
+
>
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Anchor as returned in `Message` / `Danmaku` API shapes: the resource
|
|
69
|
+
* the anchor points into plus the plugin location payload. The server
|
|
70
|
+
* derives `resId` from the row's own `anchor_resource_id` column —
|
|
71
|
+
* plugin code never supplies it (see `anchorData` in
|
|
72
|
+
* `@hoardodile/sdk-types/schema`).
|
|
73
|
+
*/
|
|
74
|
+
export type ResAnchor = {
|
|
75
|
+
readonly resId: string
|
|
76
|
+
readonly data?: unknown
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Web plugin message shape. */
|
|
80
|
+
export type Message = {
|
|
81
|
+
readonly id: string
|
|
82
|
+
readonly parentId?: string
|
|
83
|
+
readonly body: string
|
|
84
|
+
readonly createdAt: number
|
|
85
|
+
readonly deletedAt?: number
|
|
86
|
+
readonly charIds: readonly string[]
|
|
87
|
+
readonly resIds: readonly string[]
|
|
88
|
+
readonly likeCount: number
|
|
89
|
+
readonly dislikeCount: number
|
|
90
|
+
readonly replyCount: number
|
|
91
|
+
readonly floor?: number
|
|
92
|
+
readonly anchor?: ResAnchor
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Web plugin danmaku shape. */
|
|
96
|
+
export type Danmaku = {
|
|
97
|
+
readonly id: string
|
|
98
|
+
readonly anchor: ResAnchor
|
|
99
|
+
readonly text: string
|
|
100
|
+
readonly color: string
|
|
101
|
+
readonly mode: DanmakuMode
|
|
102
|
+
readonly createdAt: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Plugin-facing file stats slice of a resource. */
|
|
106
|
+
export type FileStats = {
|
|
107
|
+
readonly sizeBytes?: number
|
|
108
|
+
readonly count?: number
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Plugin-produced search metadata. The host enforces its own schema at ingestion time. */
|
|
112
|
+
export type SearchMeta = {
|
|
113
|
+
readonly v: number
|
|
114
|
+
readonly facets?: Readonly<Record<string, boolean>>
|
|
115
|
+
}
|