@jini-ai/cms 0.5.5 → 0.5.6
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/dist/consent/express/index.d.ts +53 -0
- package/dist/consent/express/index.js +159 -0
- package/dist/consent/express/response.d.ts +21 -0
- package/dist/consent/express/response.js +89 -0
- package/dist/consent/html/client.d.ts +6 -0
- package/dist/consent/html/client.js +135 -0
- package/dist/consent/html/gate.d.ts +29 -0
- package/dist/consent/html/gate.js +104 -0
- package/dist/consent/html/index.d.ts +4 -0
- package/dist/consent/html/index.js +3 -0
- package/dist/consent/html/render.d.ts +27 -0
- package/dist/consent/html/render.js +62 -0
- package/dist/consent/html/static.d.ts +7 -0
- package/dist/consent/html/static.js +25 -0
- package/dist/consent/html/styles.d.ts +3 -0
- package/dist/consent/html/styles.js +22 -0
- package/dist/consent/index.d.ts +3 -0
- package/dist/consent/index.js +3 -0
- package/dist/consent/node/index.d.ts +47 -0
- package/dist/consent/node/index.js +169 -0
- package/dist/consent/policy.d.ts +47 -0
- package/dist/consent/policy.js +62 -0
- package/dist/consent/types.d.ts +40 -0
- package/dist/consent/types.js +5 -0
- package/dist/content-types/agent-tools.js +1 -1
- package/dist/content-types/tool-registrations.d.ts +2 -0
- package/dist/content-types/tool-registrations.js +1 -0
- package/dist/content-types/write-service.d.ts +2 -0
- package/dist/content-types/write-service.js +3 -0
- package/dist/forms/descriptor-rules.d.ts +9 -0
- package/dist/forms/descriptor-rules.js +112 -0
- package/dist/forms/email-format.d.ts +7 -0
- package/dist/forms/email-format.js +11 -0
- package/dist/forms/express/handler.js +4 -8
- package/dist/forms/field-registry.d.ts +193 -0
- package/dist/forms/field-registry.js +231 -0
- package/dist/forms/forms.d.ts +8 -5
- package/dist/forms/forms.js +29 -46
- package/dist/forms/html/html-authoring.js +63 -13
- package/dist/forms/html/html-submission-adapter.d.ts +5 -5
- package/dist/forms/html/html-submission-adapter.js +6 -13
- package/dist/forms/html-render.d.ts +10 -3
- package/dist/forms/html-render.js +116 -14
- package/dist/forms/index.d.ts +1 -0
- package/dist/forms/index.js +1 -0
- package/dist/forms/repo.memory.js +4 -4
- package/dist/forms/submission-attempts.d.ts +1 -1
- package/dist/forms/submission-attempts.js +13 -2
- package/dist/forms/submission-email-validation.js +2 -4
- package/dist/forms/submit-service.d.ts +3 -0
- package/dist/forms/submit-service.js +38 -5
- package/dist/forms/types.d.ts +50 -2
- package/dist/listing/batch.d.ts +53 -0
- package/dist/listing/batch.js +57 -0
- package/dist/listing/html.d.ts +72 -0
- package/dist/listing/html.js +109 -0
- package/dist/listing/index.d.ts +5 -0
- package/dist/listing/index.js +5 -0
- package/dist/listing/paths.d.ts +32 -0
- package/dist/listing/paths.js +37 -0
- package/dist/listing/see-more-script.d.ts +16 -0
- package/dist/listing/see-more-script.js +62 -0
- package/dist/media/content-type-sniffer.d.ts +1 -1
- package/dist/media/content-type-sniffer.js +15 -0
- package/dist/media/image-transformer.sharp.js +4 -1
- package/dist/media/media-service.d.ts +4 -0
- package/dist/media/media-service.js +8 -0
- package/dist/media/node.d.ts +1 -1
- package/dist/media/node.js +1 -1
- package/dist/media/transform-registry.js +1 -1
- package/dist/media/transform-types.d.ts +2 -0
- package/dist/media/transform-types.js +3 -0
- package/dist/media/video-frames.ffmpeg.d.ts +15 -2
- package/dist/media/video-frames.ffmpeg.js +28 -13
- package/dist/navigation/dropdown.d.ts +41 -0
- package/dist/navigation/dropdown.js +85 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.js +2 -0
- package/dist/redirects/capture.d.ts +2 -0
- package/dist/redirects/capture.js +4 -0
- package/dist/redirects/redirects.d.ts +8 -0
- package/dist/redirects/redirects.js +14 -0
- package/dist/routing/index.d.ts +5 -0
- package/dist/routing/index.js +2 -0
- package/dist/routing/ports.d.ts +51 -0
- package/dist/routing/ports.js +2 -0
- package/dist/routing/routing.d.ts +138 -0
- package/dist/routing/routing.js +443 -0
- package/dist/routing/types.d.ts +164 -0
- package/dist/routing/types.js +2 -0
- package/dist/seo/media.d.ts +23 -0
- package/dist/seo/media.js +30 -5
- package/dist/seo/page-head.js +8 -1
- package/dist/seo/seo.d.ts +2 -0
- package/dist/seo/seo.js +11 -6
- package/dist/seo/types.d.ts +8 -0
- package/dist/seo/xml-escape.d.ts +1 -3
- package/dist/seo/xml-escape.js +1 -6
- package/dist/widgets/concurrency.d.ts +27 -1
- package/dist/widgets/concurrency.js +12 -23
- package/dist/widgets/config-validation.js +6 -0
- package/dist/widgets/gallery-resolver.d.ts +23 -0
- package/dist/widgets/gallery-resolver.js +80 -0
- package/dist/widgets/html/gallery-render.d.ts +24 -0
- package/dist/widgets/html/gallery-render.js +65 -0
- package/dist/widgets/html/html-embeds.d.ts +4 -0
- package/dist/widgets/html/html-embeds.js +7 -1
- package/dist/widgets/html/index.d.ts +3 -0
- package/dist/widgets/html/index.js +3 -0
- package/dist/widgets/html/server-fragment.d.ts +9 -0
- package/dist/widgets/html/server-fragment.js +38 -0
- package/dist/widgets/markers/gallery.d.ts +50 -0
- package/dist/widgets/markers/gallery.js +164 -0
- package/dist/widgets/markers/html-attributes.d.ts +6 -1
- package/dist/widgets/markers/html-attributes.js +24 -9
- package/dist/widgets/markers/index.d.ts +2 -0
- package/dist/widgets/markers/index.js +2 -0
- package/dist/widgets/markers/marker.d.ts +4 -0
- package/dist/widgets/markers/marker.js +6 -0
- package/dist/widgets/markers/media-reference.d.ts +11 -0
- package/dist/widgets/markers/media-reference.js +11 -0
- package/dist/widgets/page-embed-types.d.ts +1 -1
- package/dist/widgets/page-embed-types.js +1 -1
- package/dist/widgets/registry.d.ts +144 -10
- package/dist/widgets/registry.js +40 -0
- package/dist/widgets/resolver-service.d.ts +10 -0
- package/dist/widgets/resolver-service.js +73 -29
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
- package/dist/widgets/resolvers/gallery.d.ts +12 -0
- package/dist/widgets/resolvers/gallery.js +33 -0
- package/dist/widgets/resolvers/index.d.ts +1 -0
- package/dist/widgets/resolvers/index.js +1 -0
- package/dist/widgets/types.d.ts +3 -1
- package/package.json +51 -6
|
@@ -46,10 +46,15 @@ export async function submitForm(required, optional = {}) {
|
|
|
46
46
|
if (isHoneypotTripped({ hp: input.body[HONEYPOT_KEY] })) {
|
|
47
47
|
return { status: "accepted" };
|
|
48
48
|
}
|
|
49
|
-
const validation = validateSubmissionPayload({ definition, body: input.body });
|
|
49
|
+
const validation = validateSubmissionPayload({ definition, body: input.body }, deps.files ? { files: deps.files } : {});
|
|
50
50
|
if (!validation.valid) {
|
|
51
51
|
throw new FormSubmissionValidationError({ message: "submission failed field validation", fieldErrors: validation.fieldErrors });
|
|
52
52
|
}
|
|
53
|
+
if (validation.files && !deps.transaction) {
|
|
54
|
+
// Without rollback, a failed outbox write would leave a stored row pointing at compensated
|
|
55
|
+
// files. Existing scalar-only hosts retain their legacy optional-transaction contract.
|
|
56
|
+
throw new FormSubmissionValidationError({ message: "file submissions require transactional storage", fieldErrors: Object.keys(validation.files).map((field) => ({ field, reason: "file_storage_unavailable" })) });
|
|
57
|
+
}
|
|
53
58
|
// Scope by both IP and form so one client's different forms cannot cross-throttle each other.
|
|
54
59
|
const rateLimitKey = buildFormsRateLimitKey({ sourceIp: input.sourceIp, formDefinitionId: definition.id });
|
|
55
60
|
const rateLimitResult = await deps.rateLimiter.check({ key: rateLimitKey });
|
|
@@ -77,10 +82,38 @@ export async function submitForm(required, optional = {}) {
|
|
|
77
82
|
// Only a persisted accepted submission gets this event; honeypot discards emit nothing. Row and
|
|
78
83
|
// event commit together: a failed enqueue must not leave a stored submission nobody is told about.
|
|
79
84
|
const transaction = deps.transaction ?? ((work) => work());
|
|
80
|
-
const persist =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
const persist = async () => {
|
|
86
|
+
// Blob backends cannot participate in a SQL transaction. Store within the dedupe claim,
|
|
87
|
+
// then compensate only this attempt's new media assets when any upload/row/event write fails.
|
|
88
|
+
// Byte buffers never reach data_json, notifications or the outbox payload.
|
|
89
|
+
const uploaded = [];
|
|
90
|
+
try {
|
|
91
|
+
for (const [fieldId, file] of Object.entries(validation.files ?? {})) {
|
|
92
|
+
const reference = await deps.files.store({ workspaceId: input.workspaceId, submissionId, fieldId, file });
|
|
93
|
+
uploaded.push(reference);
|
|
94
|
+
submission.data[fieldId] = reference;
|
|
95
|
+
}
|
|
96
|
+
await transaction(async () => {
|
|
97
|
+
await deps.submissionRepo.create(submission);
|
|
98
|
+
await deps.outbox.enqueue(event);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
for (const reference of uploaded) {
|
|
103
|
+
try {
|
|
104
|
+
await deps.files.discard({ workspaceId: input.workspaceId, reference });
|
|
105
|
+
}
|
|
106
|
+
catch (cleanupError) {
|
|
107
|
+
// Retain the original submission failure; a failed cleanup is visible to operators.
|
|
108
|
+
try {
|
|
109
|
+
deps.logger.warn({ message: "form upload cleanup failed" }, { error: cleanupError });
|
|
110
|
+
}
|
|
111
|
+
catch { /* Logging cannot replace the original error. */ }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
84
117
|
if (optional.attempts) {
|
|
85
118
|
// The attempt is claimed only where persistence begins, after validation and rate limiting.
|
|
86
119
|
// A copy waits outside the database transaction; failed first copies free the claim for retry.
|
package/dist/forms/types.d.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import type { UUID } from "@jini-ai/core/primitives";
|
|
2
|
-
export type FieldType =
|
|
2
|
+
export type FieldType = keyof typeof import("./field-registry.js").FIELD_TYPE_REGISTRY;
|
|
3
3
|
export type FormDefinitionStatus = "active" | "disabled";
|
|
4
4
|
export interface FieldDescriptor {
|
|
5
5
|
id: string;
|
|
6
6
|
label: string;
|
|
7
7
|
type: FieldType;
|
|
8
8
|
required: boolean;
|
|
9
|
+
/** Optional HTML name; must equal the stable id so historic submission keys remain valid. */
|
|
10
|
+
name?: string;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
helpText?: string;
|
|
13
|
+
default?: string | number | boolean | string[];
|
|
14
|
+
options?: Array<{
|
|
15
|
+
value: string;
|
|
16
|
+
label: string;
|
|
17
|
+
}>;
|
|
18
|
+
minLength?: number;
|
|
9
19
|
maxLength?: number | null;
|
|
20
|
+
/** Full-match, bounded safe regular expression; groups/alternation/backreferences are refused. */
|
|
21
|
+
pattern?: string;
|
|
22
|
+
min?: number | string;
|
|
23
|
+
max?: number | string;
|
|
24
|
+
step?: number;
|
|
25
|
+
/** Omitted accept means every recognized safe type, never executable/markup/archive formats. */
|
|
26
|
+
accept?: string[];
|
|
27
|
+
maxFileSize?: number;
|
|
10
28
|
/** Presence distinguishes an authored checkbox value (even "") from the native boolean default. */
|
|
11
29
|
checkboxValue?: string;
|
|
12
30
|
/** Length-bounded and HTML-escaped at rendering; class names are inert after escaping. */
|
|
@@ -37,7 +55,7 @@ export interface FormSubmissionRecord {
|
|
|
37
55
|
id: UUID;
|
|
38
56
|
workspaceId: UUID;
|
|
39
57
|
formDefinitionId: UUID;
|
|
40
|
-
data: Record<string,
|
|
58
|
+
data: Record<string, FormAnswer>;
|
|
41
59
|
/** Cleared by metadata retention; answers and the submission itself remain. */
|
|
42
60
|
sourceIp: string | null;
|
|
43
61
|
submittedAt: string;
|
|
@@ -46,4 +64,34 @@ export interface FormSubmissionPage {
|
|
|
46
64
|
items: FormSubmissionRecord[];
|
|
47
65
|
nextCursor: string | null;
|
|
48
66
|
}
|
|
67
|
+
/** Byte-bearing input exists only in memory at the submission boundary. Never stored as JSON. */
|
|
68
|
+
export interface FormFileInput {
|
|
69
|
+
filename: string;
|
|
70
|
+
contentType: string;
|
|
71
|
+
bytes: Uint8Array;
|
|
72
|
+
}
|
|
73
|
+
export interface FormFileReference {
|
|
74
|
+
mediaId: string;
|
|
75
|
+
filename: string;
|
|
76
|
+
contentType: string;
|
|
77
|
+
byteSize: number;
|
|
78
|
+
}
|
|
79
|
+
export type FormAnswer = string | boolean | number | string[] | FormFileReference;
|
|
80
|
+
/** Hosts sniff bytes and persist accepted originals via their existing media/storage owner. */
|
|
81
|
+
export interface FormFileStoragePort {
|
|
82
|
+
detectContentType(required: {
|
|
83
|
+
bytes: Uint8Array;
|
|
84
|
+
}, optional?: Record<string, never>): string;
|
|
85
|
+
store(required: {
|
|
86
|
+
workspaceId: string;
|
|
87
|
+
submissionId: string;
|
|
88
|
+
fieldId: string;
|
|
89
|
+
file: FormFileInput;
|
|
90
|
+
}, optional?: Record<string, never>): Promise<FormFileReference>;
|
|
91
|
+
/** Compensates originals created by this attempt if the submission transaction fails. */
|
|
92
|
+
discard(required: {
|
|
93
|
+
workspaceId: string;
|
|
94
|
+
reference: FormFileReference;
|
|
95
|
+
}, optional?: Record<string, never>): Promise<void>;
|
|
96
|
+
}
|
|
49
97
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "See more" listing batches: the one read rule every progressive listing (posts, collections, a
|
|
3
|
+
* future JSON endpoint) shares. `after`/`next` count SOURCE rows already consumed, not visible ones,
|
|
4
|
+
* so visibility (member access included) is applied before items are taken and a hidden row is
|
|
5
|
+
* never counted twice or shown out of order.
|
|
6
|
+
*/
|
|
7
|
+
/** Batch size a see-more listing uses when its config names none. */
|
|
8
|
+
export declare const LISTING_BATCH_DEFAULT = 12;
|
|
9
|
+
/** Ceiling on one batch, so neither an absent nor an adversarial config turns a listing into a scan. */
|
|
10
|
+
export declare const LISTING_BATCH_MAX = 24;
|
|
11
|
+
/** Ceiling on the source cursor: OFFSET cost is O(after), so a forged huge cursor is refused. */
|
|
12
|
+
export declare const LISTING_AFTER_MAX = 100000;
|
|
13
|
+
/** Reads `limit` source rows starting `offset` rows in, in the listing's own stable order. */
|
|
14
|
+
export type ListingSource<T> = (page: {
|
|
15
|
+
offset: number;
|
|
16
|
+
limit: number;
|
|
17
|
+
}) => Promise<readonly T[]>;
|
|
18
|
+
export interface ListingBatch<T> {
|
|
19
|
+
readonly items: readonly T[];
|
|
20
|
+
/** A further visible item exists after `items`. */
|
|
21
|
+
readonly hasMore: boolean;
|
|
22
|
+
/** Source cursor for the next batch (pass back as `after`). */
|
|
23
|
+
readonly next: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A listing's batch size from its config: `config[limitKey]` floored and clamped into
|
|
27
|
+
* `[1, LISTING_BATCH_MAX]`, else `fallback` when the key is absent or not a positive finite number.
|
|
28
|
+
* @complexity O(1).
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseListingBatchSize({ config }: {
|
|
31
|
+
config: Readonly<Record<string, unknown>>;
|
|
32
|
+
}, { limitKey, fallback }?: {
|
|
33
|
+
limitKey?: string;
|
|
34
|
+
fallback?: number;
|
|
35
|
+
}): number;
|
|
36
|
+
/**
|
|
37
|
+
* One batch of up to `size` visible items starting at source cursor `after`. Reads `size + 1` rows a
|
|
38
|
+
* round (the extra row answers `hasMore` without a COUNT), at most {@link LISTING_SCAN_ROUNDS}
|
|
39
|
+
* rounds, so a run of hidden rows can shorten a batch but never make it unbounded.
|
|
40
|
+
* @complexity O(rounds) source reads of `size + 1` rows each, plus O(rows) `isVisible` calls.
|
|
41
|
+
*/
|
|
42
|
+
export declare function readListingBatch<T>({ source, after, size }: {
|
|
43
|
+
source: ListingSource<T>;
|
|
44
|
+
after: number;
|
|
45
|
+
size: number;
|
|
46
|
+
}, { isVisible }?: {
|
|
47
|
+
isVisible?: (item: T) => boolean;
|
|
48
|
+
}): Promise<ListingBatch<T>>;
|
|
49
|
+
/** An in-memory {@link ListingSource} over an already-ordered array. @complexity O(limit). */
|
|
50
|
+
export declare function arrayListingSource<T>({ rows }: {
|
|
51
|
+
rows: readonly T[];
|
|
52
|
+
}, _optional?: Record<string, never>): ListingSource<T>;
|
|
53
|
+
//# sourceMappingURL=batch.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "See more" listing batches: the one read rule every progressive listing (posts, collections, a
|
|
3
|
+
* future JSON endpoint) shares. `after`/`next` count SOURCE rows already consumed, not visible ones,
|
|
4
|
+
* so visibility (member access included) is applied before items are taken and a hidden row is
|
|
5
|
+
* never counted twice or shown out of order.
|
|
6
|
+
*/
|
|
7
|
+
/** Batch size a see-more listing uses when its config names none. */
|
|
8
|
+
export const LISTING_BATCH_DEFAULT = 12;
|
|
9
|
+
/** Ceiling on one batch, so neither an absent nor an adversarial config turns a listing into a scan. */
|
|
10
|
+
export const LISTING_BATCH_MAX = 24;
|
|
11
|
+
/** Ceiling on the source cursor: OFFSET cost is O(after), so a forged huge cursor is refused. */
|
|
12
|
+
export const LISTING_AFTER_MAX = 100_000;
|
|
13
|
+
/** Scan rounds before a batch is returned short; `hasMore` stays correct either way. */
|
|
14
|
+
const LISTING_SCAN_ROUNDS = 4;
|
|
15
|
+
/**
|
|
16
|
+
* A listing's batch size from its config: `config[limitKey]` floored and clamped into
|
|
17
|
+
* `[1, LISTING_BATCH_MAX]`, else `fallback` when the key is absent or not a positive finite number.
|
|
18
|
+
* @complexity O(1).
|
|
19
|
+
*/
|
|
20
|
+
export function parseListingBatchSize({ config }, { limitKey = 'limit', fallback = LISTING_BATCH_DEFAULT } = {}) {
|
|
21
|
+
const raw = config[limitKey];
|
|
22
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0)
|
|
23
|
+
return fallback;
|
|
24
|
+
return Math.max(1, Math.min(Math.floor(raw), LISTING_BATCH_MAX));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* One batch of up to `size` visible items starting at source cursor `after`. Reads `size + 1` rows a
|
|
28
|
+
* round (the extra row answers `hasMore` without a COUNT), at most {@link LISTING_SCAN_ROUNDS}
|
|
29
|
+
* rounds, so a run of hidden rows can shorten a batch but never make it unbounded.
|
|
30
|
+
* @complexity O(rounds) source reads of `size + 1` rows each, plus O(rows) `isVisible` calls.
|
|
31
|
+
*/
|
|
32
|
+
export async function readListingBatch({ source, after, size }, { isVisible = () => true } = {}) {
|
|
33
|
+
const items = [];
|
|
34
|
+
let cursor = Math.max(0, Math.min(Math.floor(after), LISTING_AFTER_MAX));
|
|
35
|
+
for (let round = 0; round < LISTING_SCAN_ROUNDS; round += 1) {
|
|
36
|
+
const rows = await source({ offset: cursor, limit: size + 1 });
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
if (!isVisible(row)) {
|
|
39
|
+
cursor += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// A visible row past a full batch only proves there is more; it stays unconsumed.
|
|
43
|
+
if (items.length === size)
|
|
44
|
+
return { items, hasMore: true, next: cursor };
|
|
45
|
+
items.push(row);
|
|
46
|
+
cursor += 1;
|
|
47
|
+
}
|
|
48
|
+
if (rows.length < size + 1)
|
|
49
|
+
return { items, hasMore: false, next: cursor };
|
|
50
|
+
}
|
|
51
|
+
return { items, hasMore: true, next: cursor };
|
|
52
|
+
}
|
|
53
|
+
/** An in-memory {@link ListingSource} over an already-ordered array. @complexity O(limit). */
|
|
54
|
+
export function arrayListingSource({ rows }, _optional = {}) {
|
|
55
|
+
return async ({ offset, limit }) => rows.slice(offset, offset + limit);
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=batch.js.map
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String-level markup for see-more listings, so no HTML parser is needed:
|
|
3
|
+
* - the author marks a listing container with `data-jini-see-more`; {@link assignListingKeys} turns
|
|
4
|
+
* that into `data-jini-listing="<key>"` (1-based, document order) before the listing renders;
|
|
5
|
+
* - the items sit between `<!--jini-listing:<key>-->` sentinels ({@link wrapListingItems}), which is
|
|
6
|
+
* what {@link extractListingBatch} slices a batch response out of;
|
|
7
|
+
* - the button is a `<button>` carrying the next batch URL in `data-jini-more-href`
|
|
8
|
+
* ({@link renderListingMore}), `hidden` until the inline script runs, so with no JS it is simply
|
|
9
|
+
* absent (a batch URL alone is a bare fragment, so a plain link adds nothing for no-JS readers);
|
|
10
|
+
* - a listing that carries a `<template data-jini-card>` card ({@link extractListingCardTemplate})
|
|
11
|
+
* loads its next batches as JSON (`.json` batch URLs) and the script fills a copy of the card per
|
|
12
|
+
* item; without one it keeps loading server-rendered HTML batches.
|
|
13
|
+
*/
|
|
14
|
+
export declare const LISTING_SEE_MORE_ATTRIBUTE = "data-jini-see-more";
|
|
15
|
+
/** Rewrites each `data-jini-see-more` attribute to `data-jini-listing="<key>"` in document order;
|
|
16
|
+
* past {@link LISTING_KEY_MAX} the attribute is dropped and the listing renders plain. @complexity O(n). */
|
|
17
|
+
export declare function assignListingKeys({ html }: {
|
|
18
|
+
html: string;
|
|
19
|
+
}, _optional?: Record<string, never>): string;
|
|
20
|
+
/** The listing key an element's attribute string carries, if any. @complexity O(n) over `attrs`. */
|
|
21
|
+
export declare function listingKeyOfAttributes({ attrs }: {
|
|
22
|
+
attrs: string;
|
|
23
|
+
}, _optional?: Record<string, never>): number | undefined;
|
|
24
|
+
/** `items` between the sentinels {@link extractListingBatch} slices on. @complexity O(n). */
|
|
25
|
+
export declare function wrapListingItems({ key, items }: {
|
|
26
|
+
key: number;
|
|
27
|
+
items: string;
|
|
28
|
+
}, _optional?: Record<string, never>): string;
|
|
29
|
+
/** The see-more button for `key`'s listing, carrying its next batch URL; the script shows
|
|
30
|
+
* `loadingLabel` (disabled) while a batch is fetched. @complexity O(n) over `href`. */
|
|
31
|
+
export declare function renderListingMore({ key, href }: {
|
|
32
|
+
key: number;
|
|
33
|
+
href: string;
|
|
34
|
+
}, { label, loadingLabel }?: {
|
|
35
|
+
label?: string;
|
|
36
|
+
loadingLabel?: string;
|
|
37
|
+
}): string;
|
|
38
|
+
/** Adds the one inline see-more script before `</body>` when the page carries a see-more button and
|
|
39
|
+
* does not already have the script. @complexity O(n). */
|
|
40
|
+
export declare function injectListingScript({ html }: {
|
|
41
|
+
html: string;
|
|
42
|
+
}, _optional?: Record<string, never>): string;
|
|
43
|
+
/** `key`'s rendered items and next-batch href, or `undefined` when `html` has no such listing. @complexity O(n). */
|
|
44
|
+
export declare function extractListingBatch({ html, key }: {
|
|
45
|
+
html: string;
|
|
46
|
+
key: number;
|
|
47
|
+
}, _optional?: Record<string, never>): {
|
|
48
|
+
items: string;
|
|
49
|
+
moreHref?: string;
|
|
50
|
+
} | undefined;
|
|
51
|
+
/** The document a batch URL serves: the items plus the next button, or none on the last batch. @complexity O(n). */
|
|
52
|
+
export declare function renderListingBatchDocument({ key, items, moreHref }: {
|
|
53
|
+
key: number;
|
|
54
|
+
items: string;
|
|
55
|
+
moreHref?: string;
|
|
56
|
+
}, _optional?: Record<string, never>): string;
|
|
57
|
+
/** Every see-more button's batch URL in `html`, for a static exporter to follow. @complexity O(n). */
|
|
58
|
+
export declare function extractListingMoreHrefs({ html }: {
|
|
59
|
+
html: string;
|
|
60
|
+
}, _optional?: Record<string, never>): readonly string[];
|
|
61
|
+
/** The first `<template data-jini-card>…</template>` element in a listing's authored markup, whole,
|
|
62
|
+
* or `undefined` when the listing has no client-side card. @complexity O(n). */
|
|
63
|
+
export declare function extractListingCardTemplate({ html }: {
|
|
64
|
+
html: string;
|
|
65
|
+
}, _optional?: Record<string, never>): string | undefined;
|
|
66
|
+
/** The next `.json` batch URL a JSON batch at `path` points to, for a static exporter to follow, or
|
|
67
|
+
* `undefined` after the last batch or for a body that is not a JSON batch. @complexity O(n). */
|
|
68
|
+
export declare function listingJsonNextPath({ path, body }: {
|
|
69
|
+
path: string;
|
|
70
|
+
body: string;
|
|
71
|
+
}, _optional?: Record<string, never>): string | undefined;
|
|
72
|
+
//# sourceMappingURL=html.d.ts.map
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { LISTING_KEY_MAX, listingBatchPath, parseListingBatchPath } from './paths.js';
|
|
2
|
+
import { LISTING_SEE_MORE_SCRIPT } from './see-more-script.js';
|
|
3
|
+
/**
|
|
4
|
+
* String-level markup for see-more listings, so no HTML parser is needed:
|
|
5
|
+
* - the author marks a listing container with `data-jini-see-more`; {@link assignListingKeys} turns
|
|
6
|
+
* that into `data-jini-listing="<key>"` (1-based, document order) before the listing renders;
|
|
7
|
+
* - the items sit between `<!--jini-listing:<key>-->` sentinels ({@link wrapListingItems}), which is
|
|
8
|
+
* what {@link extractListingBatch} slices a batch response out of;
|
|
9
|
+
* - the button is a `<button>` carrying the next batch URL in `data-jini-more-href`
|
|
10
|
+
* ({@link renderListingMore}), `hidden` until the inline script runs, so with no JS it is simply
|
|
11
|
+
* absent (a batch URL alone is a bare fragment, so a plain link adds nothing for no-JS readers);
|
|
12
|
+
* - a listing that carries a `<template data-jini-card>` card ({@link extractListingCardTemplate})
|
|
13
|
+
* loads its next batches as JSON (`.json` batch URLs) and the script fills a copy of the card per
|
|
14
|
+
* item; without one it keeps loading server-rendered HTML batches.
|
|
15
|
+
*/
|
|
16
|
+
export const LISTING_SEE_MORE_ATTRIBUTE = 'data-jini-see-more';
|
|
17
|
+
const SEE_MORE_IN_OPEN_TAG = /(<[a-z][a-z0-9-]*\b[^<>]*?\s)data-jini-see-more(?:=(?:""|''))?(?=[\s/>])/gi;
|
|
18
|
+
const LISTING_KEY_ATTRIBUTE = /(?:^|\s)data-jini-listing="(\d+)"/;
|
|
19
|
+
const SCRIPT_MARKER = 'data-jini-see-more-script';
|
|
20
|
+
function escapeAttribute(value) {
|
|
21
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
22
|
+
}
|
|
23
|
+
function decodeAttribute(value) {
|
|
24
|
+
return value.replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
25
|
+
}
|
|
26
|
+
/** Rewrites each `data-jini-see-more` attribute to `data-jini-listing="<key>"` in document order;
|
|
27
|
+
* past {@link LISTING_KEY_MAX} the attribute is dropped and the listing renders plain. @complexity O(n). */
|
|
28
|
+
export function assignListingKeys({ html }, _optional = {}) {
|
|
29
|
+
let key = 0;
|
|
30
|
+
return html.replace(SEE_MORE_IN_OPEN_TAG, (_whole, before) => {
|
|
31
|
+
key += 1;
|
|
32
|
+
return key > LISTING_KEY_MAX ? before.trimEnd() : `${before}data-jini-listing="${key}"`;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** The listing key an element's attribute string carries, if any. @complexity O(n) over `attrs`. */
|
|
36
|
+
export function listingKeyOfAttributes({ attrs }, _optional = {}) {
|
|
37
|
+
const match = LISTING_KEY_ATTRIBUTE.exec(attrs);
|
|
38
|
+
return match ? Number(match[1]) : undefined;
|
|
39
|
+
}
|
|
40
|
+
/** `items` between the sentinels {@link extractListingBatch} slices on. @complexity O(n). */
|
|
41
|
+
export function wrapListingItems({ key, items }, _optional = {}) {
|
|
42
|
+
return `<!--jini-listing:${key}-->${items}<!--/jini-listing:${key}-->`;
|
|
43
|
+
}
|
|
44
|
+
/** The see-more button for `key`'s listing, carrying its next batch URL; the script shows
|
|
45
|
+
* `loadingLabel` (disabled) while a batch is fetched. @complexity O(n) over `href`. */
|
|
46
|
+
export function renderListingMore({ key, href }, { label = 'See more', loadingLabel = 'Loading…' } = {}) {
|
|
47
|
+
return `<button type="button" class="jini-see-more" data-jini-more="${key}" data-jini-more-href="${escapeAttribute(href)}" data-jini-loading="${escapeAttribute(loadingLabel)}" hidden>${escapeAttribute(label)}</button>`;
|
|
48
|
+
}
|
|
49
|
+
/** Adds the one inline see-more script before `</body>` when the page carries a see-more button and
|
|
50
|
+
* does not already have the script. @complexity O(n). */
|
|
51
|
+
export function injectListingScript({ html }, _optional = {}) {
|
|
52
|
+
if (!html.includes('data-jini-more=') || html.includes(SCRIPT_MARKER))
|
|
53
|
+
return html;
|
|
54
|
+
const script = `<script ${SCRIPT_MARKER}>${LISTING_SEE_MORE_SCRIPT}</script>`;
|
|
55
|
+
const bodyClose = html.lastIndexOf('</body>');
|
|
56
|
+
return bodyClose === -1 ? html + script : html.slice(0, bodyClose) + script + html.slice(bodyClose);
|
|
57
|
+
}
|
|
58
|
+
function moreHrefsIn(html, key) {
|
|
59
|
+
const hrefs = [];
|
|
60
|
+
for (const [tag] of html.matchAll(/<button\b[^<>]*\bdata-jini-more="(\d+)"[^<>]*>/g)) {
|
|
61
|
+
if (key !== undefined && !tag.includes(`data-jini-more="${key}"`))
|
|
62
|
+
continue;
|
|
63
|
+
const href = /\bdata-jini-more-href="([^"]*)"/.exec(tag)?.[1];
|
|
64
|
+
if (href !== undefined)
|
|
65
|
+
hrefs.push(decodeAttribute(href));
|
|
66
|
+
}
|
|
67
|
+
return hrefs;
|
|
68
|
+
}
|
|
69
|
+
/** `key`'s rendered items and next-batch href, or `undefined` when `html` has no such listing. @complexity O(n). */
|
|
70
|
+
export function extractListingBatch({ html, key }, _optional = {}) {
|
|
71
|
+
const open = `<!--jini-listing:${key}-->`;
|
|
72
|
+
const start = html.indexOf(open);
|
|
73
|
+
const end = start === -1 ? -1 : html.indexOf(`<!--/jini-listing:${key}-->`, start);
|
|
74
|
+
if (end === -1)
|
|
75
|
+
return undefined;
|
|
76
|
+
const moreHref = moreHrefsIn(html.slice(end), key)[0];
|
|
77
|
+
return { items: html.slice(start + open.length, end), ...(moreHref !== undefined ? { moreHref } : {}) };
|
|
78
|
+
}
|
|
79
|
+
/** The document a batch URL serves: the items plus the next button, or none on the last batch. @complexity O(n). */
|
|
80
|
+
export function renderListingBatchDocument({ key, items, moreHref }, _optional = {}) {
|
|
81
|
+
const more = moreHref === undefined ? '' : renderListingMore({ key, href: moreHref }).replace(' hidden>', '>');
|
|
82
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"></head><body><div data-jini-listing-batch="${key}">${items}</div>${more}</body></html>`;
|
|
83
|
+
}
|
|
84
|
+
/** Every see-more button's batch URL in `html`, for a static exporter to follow. @complexity O(n). */
|
|
85
|
+
export function extractListingMoreHrefs({ html }, _optional = {}) {
|
|
86
|
+
return moreHrefsIn(html);
|
|
87
|
+
}
|
|
88
|
+
const CARD_TEMPLATE = /<template\b[^<>]*\sdata-jini-card(?:=(?:""|''))?[\s>][\s\S]*?<\/template>/i;
|
|
89
|
+
/** The first `<template data-jini-card>…</template>` element in a listing's authored markup, whole,
|
|
90
|
+
* or `undefined` when the listing has no client-side card. @complexity O(n). */
|
|
91
|
+
export function extractListingCardTemplate({ html }, _optional = {}) {
|
|
92
|
+
return CARD_TEMPLATE.exec(html)?.[0];
|
|
93
|
+
}
|
|
94
|
+
/** The next `.json` batch URL a JSON batch at `path` points to, for a static exporter to follow, or
|
|
95
|
+
* `undefined` after the last batch or for a body that is not a JSON batch. @complexity O(n). */
|
|
96
|
+
export function listingJsonNextPath({ path, body }, _optional = {}) {
|
|
97
|
+
const request = parseListingBatchPath({ path });
|
|
98
|
+
if (request?.format !== 'json')
|
|
99
|
+
return undefined;
|
|
100
|
+
let nextCursor;
|
|
101
|
+
try {
|
|
102
|
+
nextCursor = JSON.parse(body).nextCursor;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return typeof nextCursor === 'number' && Number.isInteger(nextCursor) && nextCursor > request.after ? listingBatchPath({ ...request, after: nextCursor }) : undefined;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=html.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch URLs: `<pagePath>/_more/<key>/<after>` (home: `/_more/1/12`). `_` is not a legal slug, so a
|
|
3
|
+
* batch URL can never collide with content, and it is a plain directory route, so a static export
|
|
4
|
+
* writes `.../<after>/index.html` that every static host serves unchanged. The JSON form of the same
|
|
5
|
+
* batch is `<pagePath>/_more/<key>/<after>.json`, exported as that file.
|
|
6
|
+
*/
|
|
7
|
+
/** Most see-more listings one page may carry; keys are their 1-based document-order ordinals. */
|
|
8
|
+
export declare const LISTING_KEY_MAX = 10;
|
|
9
|
+
export interface ListingBatchRequest {
|
|
10
|
+
/** The listing page's own path (`/` for home). */
|
|
11
|
+
readonly pagePath: string;
|
|
12
|
+
readonly key: number;
|
|
13
|
+
readonly after: number;
|
|
14
|
+
/** `'json'` for the `.json` form ({@link ListingJsonPage}); absent for the HTML batch. */
|
|
15
|
+
readonly format?: 'json';
|
|
16
|
+
}
|
|
17
|
+
/** A JSON batch's body: one page of a listing's public items plus the cursor for the next, or `null` after the last. */
|
|
18
|
+
export interface ListingJsonPage<T> {
|
|
19
|
+
readonly items: readonly T[];
|
|
20
|
+
readonly nextCursor: number | null;
|
|
21
|
+
}
|
|
22
|
+
/** The batch URL for `key`'s listing on `pagePath`, starting at source cursor `after`. @complexity O(n) over `pagePath`. */
|
|
23
|
+
export declare function listingBatchPath({ pagePath, key, after, format }: ListingBatchRequest, _optional?: Record<string, never>): string;
|
|
24
|
+
/**
|
|
25
|
+
* The batch request a URL path names, or `undefined` when it is not a well-formed batch URL: a bad
|
|
26
|
+
* key or cursor, a `.`/`..` segment, or an `admin`/`api` page are refused, never normalized.
|
|
27
|
+
* @complexity O(n) over `path`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseListingBatchPath({ path }: {
|
|
30
|
+
path: string;
|
|
31
|
+
}, _optional?: Record<string, never>): ListingBatchRequest | undefined;
|
|
32
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { LISTING_AFTER_MAX } from './batch.js';
|
|
2
|
+
/**
|
|
3
|
+
* Batch URLs: `<pagePath>/_more/<key>/<after>` (home: `/_more/1/12`). `_` is not a legal slug, so a
|
|
4
|
+
* batch URL can never collide with content, and it is a plain directory route, so a static export
|
|
5
|
+
* writes `.../<after>/index.html` that every static host serves unchanged. The JSON form of the same
|
|
6
|
+
* batch is `<pagePath>/_more/<key>/<after>.json`, exported as that file.
|
|
7
|
+
*/
|
|
8
|
+
/** Most see-more listings one page may carry; keys are their 1-based document-order ordinals. */
|
|
9
|
+
export const LISTING_KEY_MAX = 10;
|
|
10
|
+
const BATCH_PATH = /^((?:\/[^/]+)*)\/_more\/([1-9]\d?)\/(\d{1,6})(?:(\.json)|\/?)$/;
|
|
11
|
+
const RESERVED_FIRST_SEGMENTS = new Set(['admin', 'api']);
|
|
12
|
+
/** The batch URL for `key`'s listing on `pagePath`, starting at source cursor `after`. @complexity O(n) over `pagePath`. */
|
|
13
|
+
export function listingBatchPath({ pagePath, key, after, format }, _optional = {}) {
|
|
14
|
+
return `${pagePath.replace(/\/+$/, '')}/_more/${key}/${after}${format === 'json' ? '.json' : ''}`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The batch request a URL path names, or `undefined` when it is not a well-formed batch URL: a bad
|
|
18
|
+
* key or cursor, a `.`/`..` segment, or an `admin`/`api` page are refused, never normalized.
|
|
19
|
+
* @complexity O(n) over `path`.
|
|
20
|
+
*/
|
|
21
|
+
export function parseListingBatchPath({ path }, _optional = {}) {
|
|
22
|
+
const match = BATCH_PATH.exec(path);
|
|
23
|
+
if (!match)
|
|
24
|
+
return undefined;
|
|
25
|
+
const [, page = '', rawKey = '', rawAfter = '', json] = match;
|
|
26
|
+
const segments = page.split('/').filter(Boolean);
|
|
27
|
+
if (segments.some((segment) => segment === '.' || segment === '..' || segment === '_more'))
|
|
28
|
+
return undefined;
|
|
29
|
+
if (segments[0] !== undefined && RESERVED_FIRST_SEGMENTS.has(segments[0]))
|
|
30
|
+
return undefined;
|
|
31
|
+
const key = Number(rawKey);
|
|
32
|
+
const after = Number(rawAfter);
|
|
33
|
+
if (key > LISTING_KEY_MAX || after > LISTING_AFTER_MAX)
|
|
34
|
+
return undefined;
|
|
35
|
+
return { pagePath: page === '' ? '/' : page, key, after, ...(json ? { format: 'json' } : {}) };
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline script behind every see-more button ({@link injectListingScript} adds it once per page).
|
|
3
|
+
* It reveals the `hidden` buttons, then on click fetches the batch URL (`data-jini-more-href`),
|
|
4
|
+
* appends the batch's items to the `[data-jini-listing=key]` container (before the button when the
|
|
5
|
+
* button sits inside that container, so it stays below the cards) and swaps in the next URL, or
|
|
6
|
+
* removes the button after the last batch. While a fetch is in flight the button is disabled,
|
|
7
|
+
* `aria-busy`, and shows its `data-jini-loading` label; a failed fetch restores it.
|
|
8
|
+
*
|
|
9
|
+
* A `.json` batch URL (the listing has a `<template data-jini-card>`) is read as a
|
|
10
|
+
* {@link ListingJsonPage}: each item fills a copy of that card and the next URL is built from
|
|
11
|
+
* `nextCursor`. Bindings: `data-field="path"` sets `textContent`; `data-field-<attr>="path"` sets one of
|
|
12
|
+
* href/src/alt/title/datetime/width/height, and href/src only when relative or http(s). A text or
|
|
13
|
+
* href/src binding with no value removes its element. Data is never parsed as HTML.
|
|
14
|
+
*/
|
|
15
|
+
export declare const LISTING_SEE_MORE_SCRIPT = "(function(){\n var ATTRS={href:1,src:1,alt:1,title:1,datetime:1,width:1,height:1};\n function value(item,path){var v=item;var keys=path.split('.');for(var i=0;i<keys.length&&v!=null;i++)v=Object.prototype.hasOwnProperty.call(v,keys[i])?v[keys[i]]:undefined;if(Array.isArray(v))v=v.join(', ');return v==null||typeof v==='object'?'':String(v);}\n function fill(root,item){\n var els=root.querySelectorAll('*');\n for(var i=0;i<els.length;i++){var el=els[i];var drop=false;\n for(var a=el.attributes.length-1;a>=0;a--){var name=el.attributes[a].name;if(name!=='data-field'&&name.indexOf('data-field-')!==0)continue;\n var v=value(item,el.attributes[a].value);el.removeAttribute(name);\n if(name==='data-field'){if(v==='')drop=true;else el.textContent=v;continue;}\n var attr=name.slice(11);if(!ATTRS[attr])continue;\n var link=attr==='href'||attr==='src';\n if(link&&/^\\s*[a-z][a-z0-9+.-]*:/i.test(v)&&!/^\\s*https?:/i.test(v))v='';\n if(v!=='')el.setAttribute(attr,v);else if(link)drop=true;}\n if(drop&&el.parentNode)el.parentNode.removeChild(el);}\n return root;}\n var buttons=document.querySelectorAll('button[data-jini-more]');\n for(var i=0;i<buttons.length;i++)buttons[i].hidden=false;\n document.addEventListener('click',function(e){\n var b=e.target&&e.target.closest?e.target.closest('button[data-jini-more]'):null;\n if(!b||b.disabled)return;\n var list=document.querySelector('[data-jini-listing=\"'+b.getAttribute('data-jini-more')+'\"]');\n if(!list||!window.fetch||!window.DOMParser)return;\n var label=b.textContent;\n function idle(){b.disabled=false;b.removeAttribute('aria-busy');b.textContent=label;}\n b.disabled=true;b.setAttribute('aria-busy','true');b.textContent=b.getAttribute('data-jini-loading')||label;\n var href=b.getAttribute('data-jini-more-href')||'';\n var json=/\\.json$/.test(href.split(/[?#]/)[0]);\n var tpl=json?list.querySelector('template[data-jini-card]'):null;\n if(json&&!tpl)return idle();\n fetch(href,{credentials:'same-origin'}).then(function(r){if(!r.ok)throw new Error(String(r.status));return r.text();}).then(function(text){\n var anchor=list.contains(b)?b:null;\n if(json){\n var data=JSON.parse(text);\n if(!data||!Array.isArray(data.items))throw new Error('no batch');\n for(var n=0;n<data.items.length;n++)list.insertBefore(fill(tpl.content.cloneNode(true),data.items[n]),anchor);\n if(typeof data.nextCursor==='number'){b.setAttribute('data-jini-more-href',href.replace(/\\/\\d+\\.json/,'/'+data.nextCursor+'.json'));idle();}else{b.parentNode.removeChild(b);}\n return;\n }\n var doc=new DOMParser().parseFromString(text,'text/html');\n var batch=doc.querySelector('[data-jini-listing-batch]');\n if(!batch)throw new Error('no batch');\n while(batch.firstChild)list.insertBefore(document.adoptNode(batch.firstChild),anchor);\n var next=doc.querySelector('button[data-jini-more]');\n if(next){b.setAttribute('data-jini-more-href',next.getAttribute('data-jini-more-href'));idle();}else{b.parentNode.removeChild(b);}\n }).catch(idle);\n });\n })();";
|
|
16
|
+
//# sourceMappingURL=see-more-script.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline script behind every see-more button ({@link injectListingScript} adds it once per page).
|
|
3
|
+
* It reveals the `hidden` buttons, then on click fetches the batch URL (`data-jini-more-href`),
|
|
4
|
+
* appends the batch's items to the `[data-jini-listing=key]` container (before the button when the
|
|
5
|
+
* button sits inside that container, so it stays below the cards) and swaps in the next URL, or
|
|
6
|
+
* removes the button after the last batch. While a fetch is in flight the button is disabled,
|
|
7
|
+
* `aria-busy`, and shows its `data-jini-loading` label; a failed fetch restores it.
|
|
8
|
+
*
|
|
9
|
+
* A `.json` batch URL (the listing has a `<template data-jini-card>`) is read as a
|
|
10
|
+
* {@link ListingJsonPage}: each item fills a copy of that card and the next URL is built from
|
|
11
|
+
* `nextCursor`. Bindings: `data-field="path"` sets `textContent`; `data-field-<attr>="path"` sets one of
|
|
12
|
+
* href/src/alt/title/datetime/width/height, and href/src only when relative or http(s). A text or
|
|
13
|
+
* href/src binding with no value removes its element. Data is never parsed as HTML.
|
|
14
|
+
*/
|
|
15
|
+
export const LISTING_SEE_MORE_SCRIPT = `(function(){
|
|
16
|
+
var ATTRS={href:1,src:1,alt:1,title:1,datetime:1,width:1,height:1};
|
|
17
|
+
function value(item,path){var v=item;var keys=path.split('.');for(var i=0;i<keys.length&&v!=null;i++)v=Object.prototype.hasOwnProperty.call(v,keys[i])?v[keys[i]]:undefined;if(Array.isArray(v))v=v.join(', ');return v==null||typeof v==='object'?'':String(v);}
|
|
18
|
+
function fill(root,item){
|
|
19
|
+
var els=root.querySelectorAll('*');
|
|
20
|
+
for(var i=0;i<els.length;i++){var el=els[i];var drop=false;
|
|
21
|
+
for(var a=el.attributes.length-1;a>=0;a--){var name=el.attributes[a].name;if(name!=='data-field'&&name.indexOf('data-field-')!==0)continue;
|
|
22
|
+
var v=value(item,el.attributes[a].value);el.removeAttribute(name);
|
|
23
|
+
if(name==='data-field'){if(v==='')drop=true;else el.textContent=v;continue;}
|
|
24
|
+
var attr=name.slice(11);if(!ATTRS[attr])continue;
|
|
25
|
+
var link=attr==='href'||attr==='src';
|
|
26
|
+
if(link&&/^\\s*[a-z][a-z0-9+.-]*:/i.test(v)&&!/^\\s*https?:/i.test(v))v='';
|
|
27
|
+
if(v!=='')el.setAttribute(attr,v);else if(link)drop=true;}
|
|
28
|
+
if(drop&&el.parentNode)el.parentNode.removeChild(el);}
|
|
29
|
+
return root;}
|
|
30
|
+
var buttons=document.querySelectorAll('button[data-jini-more]');
|
|
31
|
+
for(var i=0;i<buttons.length;i++)buttons[i].hidden=false;
|
|
32
|
+
document.addEventListener('click',function(e){
|
|
33
|
+
var b=e.target&&e.target.closest?e.target.closest('button[data-jini-more]'):null;
|
|
34
|
+
if(!b||b.disabled)return;
|
|
35
|
+
var list=document.querySelector('[data-jini-listing="'+b.getAttribute('data-jini-more')+'"]');
|
|
36
|
+
if(!list||!window.fetch||!window.DOMParser)return;
|
|
37
|
+
var label=b.textContent;
|
|
38
|
+
function idle(){b.disabled=false;b.removeAttribute('aria-busy');b.textContent=label;}
|
|
39
|
+
b.disabled=true;b.setAttribute('aria-busy','true');b.textContent=b.getAttribute('data-jini-loading')||label;
|
|
40
|
+
var href=b.getAttribute('data-jini-more-href')||'';
|
|
41
|
+
var json=/\\.json$/.test(href.split(/[?#]/)[0]);
|
|
42
|
+
var tpl=json?list.querySelector('template[data-jini-card]'):null;
|
|
43
|
+
if(json&&!tpl)return idle();
|
|
44
|
+
fetch(href,{credentials:'same-origin'}).then(function(r){if(!r.ok)throw new Error(String(r.status));return r.text();}).then(function(text){
|
|
45
|
+
var anchor=list.contains(b)?b:null;
|
|
46
|
+
if(json){
|
|
47
|
+
var data=JSON.parse(text);
|
|
48
|
+
if(!data||!Array.isArray(data.items))throw new Error('no batch');
|
|
49
|
+
for(var n=0;n<data.items.length;n++)list.insertBefore(fill(tpl.content.cloneNode(true),data.items[n]),anchor);
|
|
50
|
+
if(typeof data.nextCursor==='number'){b.setAttribute('data-jini-more-href',href.replace(/\\/\\d+\\.json/,'/'+data.nextCursor+'.json'));idle();}else{b.parentNode.removeChild(b);}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
var doc=new DOMParser().parseFromString(text,'text/html');
|
|
54
|
+
var batch=doc.querySelector('[data-jini-listing-batch]');
|
|
55
|
+
if(!batch)throw new Error('no batch');
|
|
56
|
+
while(batch.firstChild)list.insertBefore(document.adoptNode(batch.firstChild),anchor);
|
|
57
|
+
var next=doc.querySelector('button[data-jini-more]');
|
|
58
|
+
if(next){b.setAttribute('data-jini-more-href',next.getAttribute('data-jini-more-href'));idle();}else{b.parentNode.removeChild(b);}
|
|
59
|
+
}).catch(idle);
|
|
60
|
+
});
|
|
61
|
+
})();`;
|
|
62
|
+
//# sourceMappingURL=see-more-script.js.map
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
/** Recognized outputs. Anything not covered here falls back to the network's
|
|
33
33
|
* default-safe unknown-binary label — see {@link sniffContentType}'s doc. */
|
|
34
|
-
export type SniffedContentType = "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/avif" | "video/mp4" | "video/webm" | "text/html" | "image/svg+xml" | "application/octet-stream";
|
|
34
|
+
export type SniffedContentType = "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/avif" | "video/mp4" | "video/webm" | "audio/wav" | "audio/mpeg" | "audio/flac" | "audio/mp4" | "text/html" | "image/svg+xml" | "application/octet-stream";
|
|
35
35
|
/**
|
|
36
36
|
* Identifies a media type from `bytes`' leading magic bytes. Allowlist-shaped: only the formats
|
|
37
37
|
* named in {@link SniffedContentType} are ever returned as something other than
|
|
@@ -175,10 +175,25 @@ export function sniffContentType({ bytes }, _optional = {}) {
|
|
|
175
175
|
// otherwise claim every AVIF as video/mp4 (the 2026-09-06 regression).
|
|
176
176
|
if (isAvif(bytes))
|
|
177
177
|
return "image/avif";
|
|
178
|
+
// Audio support (owner-approved mediawidget, 2026-10-10/11) shares the original-byte
|
|
179
|
+
// path. Distinguish explicit M4A brands before the existing ISO-BMFF video fallback.
|
|
180
|
+
if (isIsoBmffFtyp(bytes) && asciiEqualAt(bytes, 8, "M4A "))
|
|
181
|
+
return "audio/mp4";
|
|
178
182
|
if (isIsoBmffFtyp(bytes))
|
|
179
183
|
return "video/mp4";
|
|
180
184
|
if (isWebm(bytes))
|
|
181
185
|
return "video/webm";
|
|
186
|
+
if (asciiEqualAt(bytes, 0, "RIFF") && asciiEqualAt(bytes, 8, "WAVE"))
|
|
187
|
+
return "audio/wav";
|
|
188
|
+
if (asciiEqualAt(bytes, 0, "fLaC"))
|
|
189
|
+
return "audio/flac";
|
|
190
|
+
if (asciiEqualAt(bytes, 0, "ID3"))
|
|
191
|
+
return "audio/mpeg";
|
|
192
|
+
// Untagged MPEG audio: sync bits plus non-reserved version, layer, bitrate and sample rate.
|
|
193
|
+
if (bytes.length >= 4 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0 &&
|
|
194
|
+
(bytes[1] & 0x18) !== 0x08 && (bytes[1] & 0x06) !== 0 &&
|
|
195
|
+
(bytes[2] & 0xf0) !== 0 && (bytes[2] & 0xf0) !== 0xf0 && (bytes[2] & 0x0c) !== 0x0c)
|
|
196
|
+
return "audio/mpeg";
|
|
182
197
|
const text = leadingText(bytes);
|
|
183
198
|
if (isHtmlLike(text))
|
|
184
199
|
return "text/html";
|