@vouchington/utils 0.7.2 → 0.7.4
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/README.md +21 -0
- package/dist/ai-moderation.d.mts +34 -0
- package/dist/ai-moderation.mjs +54 -0
- package/dist/bedrock-embedding-batches.d.mts +53 -0
- package/dist/bedrock-embedding-batches.mjs +51 -0
- package/dist/bedrock-embeddings.d.mts +62 -0
- package/dist/bedrock-embeddings.mjs +106 -0
- package/dist/json-ld-profile.d.mts +12 -0
- package/dist/json-ld-profile.mjs +31 -0
- package/dist/json-ld.d.mts +57 -0
- package/dist/json-ld.mjs +117 -0
- package/dist/seo-excerpt.d.mts +2 -0
- package/dist/seo-excerpt.mjs +33 -0
- package/dist/seo-metadata.d.mts +94 -0
- package/dist/seo-metadata.mjs +97 -0
- package/dist/speculation-rules.d.mts +11 -0
- package/dist/speculation-rules.mjs +36 -0
- package/dist/web-push-binding.d.mts +51 -0
- package/dist/web-push-binding.mjs +67 -0
- package/dist/web-push-vapid.d.mts +26 -0
- package/dist/web-push-vapid.mjs +86 -0
- package/dist/web-push.d.mts +36 -0
- package/dist/web-push.mjs +114 -0
- package/package.json +46 -1
package/README.md
CHANGED
|
@@ -18,11 +18,32 @@ import { createScopeCatalog } from '@vouchington/utils/scopes'
|
|
|
18
18
|
|
|
19
19
|
## Subpaths
|
|
20
20
|
|
|
21
|
+
- `ai-moderation`: moderation input building, flagged-category and prompt-injection checks that
|
|
22
|
+
return violations; the caller makes the provider call.
|
|
23
|
+
- `bedrock-embeddings` and `bedrock-embedding-batches`: Nova `InvokeModel` input builders, response
|
|
24
|
+
parsing and dense-vector validation, and batch JSONL record and result codecs with opaque record
|
|
25
|
+
ids; the caller owns the AWS client.
|
|
21
26
|
- `cache-tags`: canonical printable-ASCII wire encoding, one-segment URL decoding, validation,
|
|
22
27
|
and escape-boundary truncation. Applications own family names, route recognition, and purge policy.
|
|
23
28
|
Single-tag validation rejects spaces and comma separators; raw commas are percent-encoded.
|
|
24
29
|
- `schema-growth`: `buildUnboundedUnpartitionedTables()` and `classifyTableGrowth()` map tables to
|
|
25
30
|
caller-supplied growth reasons. Applications own every table name and reason.
|
|
31
|
+
- `web-push`: push-subscription JSON validation with an injected endpoint policy, base64url and
|
|
32
|
+
VAPID public-key decoding, and bounded payload build/parse helpers. No sending or storage.
|
|
33
|
+
- `web-push-vapid` (Node): VAPID key generation/matching and ES256 JWT/`Authorization` builders.
|
|
34
|
+
Applications own key storage, subject, and delivery.
|
|
35
|
+
- `web-push-binding` (browser): service-worker binding-state guards, one MessageChannel
|
|
36
|
+
request/acknowledge round trip with an injected message type and acceptance check, and a named
|
|
37
|
+
Web Lock wrapper. Request actions, expected state transitions, and the bind/clear/reconcile
|
|
38
|
+
sequence stay in the application.
|
|
39
|
+
- `json-ld`: schema.org JSON-LD builders (organization, website, navigation, collection, thing,
|
|
40
|
+
comparison, item list, breadcrumb, profile) and script-safe serialization. Callers inject the
|
|
41
|
+
absolute-URL resolver and site name; product schemas such as posts stay in the application.
|
|
42
|
+
- `seo-metadata`: framework-agnostic page, root, and noindex metadata objects (structurally
|
|
43
|
+
compatible with Next.js `Metadata`), excerpts, and markdown-alternate paths. Callers inject site
|
|
44
|
+
config, image URLs, locale mapping, and alternate prefixes.
|
|
45
|
+
- `speculation-rules`: prefetch speculation-rules builder and preconnect-origin parsing with
|
|
46
|
+
caller-supplied exclusions.
|
|
26
47
|
- `sentry-envelope`: bounded UTF-8 envelope record scanning and safe item-type diagnostics.
|
|
27
48
|
Callers choose the byte and type-list limits; DSN, tunnel, request, and logging policy stay local.
|
|
28
49
|
- `scopes`: caller-owned scope catalog validation, prerequisite expansion, audience and surface
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The text or image parts a moderation endpoint accepts. */
|
|
2
|
+
export type ModerationInputPart = {
|
|
3
|
+
type: 'text';
|
|
4
|
+
text: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: 'image_url';
|
|
7
|
+
image_url: {
|
|
8
|
+
url: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
/** The fields of a moderation result this module reads. */
|
|
12
|
+
export type ModerationResultLike = {
|
|
13
|
+
flagged: boolean;
|
|
14
|
+
categories: Record<string, boolean>;
|
|
15
|
+
};
|
|
16
|
+
export type MessageSafetyViolation = {
|
|
17
|
+
code: 'PROMPT_INJECTION';
|
|
18
|
+
} | {
|
|
19
|
+
code: 'MODERATION_VIOLATION';
|
|
20
|
+
categories: string[];
|
|
21
|
+
};
|
|
22
|
+
/** Text first, then images, in the order given. */
|
|
23
|
+
export declare function buildModerationInput(texts: readonly string[], imageUrls?: readonly string[]): ModerationInputPart[];
|
|
24
|
+
/** Names of the categories a result flagged. */
|
|
25
|
+
export declare function getFlaggedCategories(result: ModerationResultLike): string[];
|
|
26
|
+
/**
|
|
27
|
+
* A violation naming every category flagged by any result, else null. Results line up with the
|
|
28
|
+
* request's inputs, so a later flagged input is never masked by a clean earlier one.
|
|
29
|
+
*/
|
|
30
|
+
export declare function checkModerationResults(results: readonly ModerationResultLike[]): MessageSafetyViolation | null;
|
|
31
|
+
/** Common instruction-override and chat-template markers. Callers may pass their own list. */
|
|
32
|
+
export declare const DEFAULT_PROMPT_INJECTION_PATTERNS: readonly RegExp[];
|
|
33
|
+
/** A `PROMPT_INJECTION` violation when any pattern matches the message, else null. */
|
|
34
|
+
export declare function checkPromptInjection(message: string, patterns?: readonly RegExp[]): MessageSafetyViolation | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Text first, then images, in the order given. */
|
|
2
|
+
export function buildModerationInput(texts, imageUrls = []) {
|
|
3
|
+
return [
|
|
4
|
+
...texts.map((text) => ({ type: 'text', text })),
|
|
5
|
+
...imageUrls.map((url) => ({ type: 'image_url', image_url: { url } })),
|
|
6
|
+
];
|
|
7
|
+
}
|
|
8
|
+
/** Names of the categories a result flagged. */
|
|
9
|
+
export function getFlaggedCategories(result) {
|
|
10
|
+
return Object.entries(result.categories).flatMap(([category, flagged]) => flagged ? [category] : []);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A violation naming every category flagged by any result, else null. Results line up with the
|
|
14
|
+
* request's inputs, so a later flagged input is never masked by a clean earlier one.
|
|
15
|
+
*/
|
|
16
|
+
export function checkModerationResults(results) {
|
|
17
|
+
const flagged = results.filter((result) => result.flagged);
|
|
18
|
+
if (flagged.length === 0)
|
|
19
|
+
return null;
|
|
20
|
+
const categories = [...new Set(flagged.flatMap(getFlaggedCategories))];
|
|
21
|
+
return { code: 'MODERATION_VIOLATION', categories };
|
|
22
|
+
}
|
|
23
|
+
/** Common instruction-override and chat-template markers. Callers may pass their own list. */
|
|
24
|
+
export const DEFAULT_PROMPT_INJECTION_PATTERNS = [
|
|
25
|
+
/ignore\s+(all\s+)?(previous|prior|earlier)\s+(instructions?|prompts?|rules?)/i,
|
|
26
|
+
/disregard\s+(all\s+)?(previous|prior|earlier)\s+(instructions?|prompts?|rules?)/i,
|
|
27
|
+
/forget\s+(all\s+)?(previous|prior|earlier)\s+(instructions?|prompts?|rules?)/i,
|
|
28
|
+
// A chat-template style role prefix at the start of a line, not "operating system: Linux".
|
|
29
|
+
/(?:^|\n)\s*system\s*:/i,
|
|
30
|
+
/<\|im_start\|>/i,
|
|
31
|
+
/<\|im_end\|>/i,
|
|
32
|
+
/\[INST\]/i,
|
|
33
|
+
/\[\/INST\]/i,
|
|
34
|
+
/<system>/i,
|
|
35
|
+
/<\/system>/i,
|
|
36
|
+
// Require "now" to avoid benign phrases like "you are a great help"
|
|
37
|
+
/you\s+are\s+now\s+(a|an)\s+/i,
|
|
38
|
+
// A heading-style "new instructions:" at the start of a line, not "new instructions for the cake".
|
|
39
|
+
/(?:^|\n)\s*new\s+instructions?\s*:/i,
|
|
40
|
+
/override\s+(previous|prior|all)\s+(instructions?|rules?)/i,
|
|
41
|
+
];
|
|
42
|
+
/** A `PROMPT_INJECTION` violation when any pattern matches the message, else null. */
|
|
43
|
+
export function checkPromptInjection(message, patterns = DEFAULT_PROMPT_INJECTION_PATTERNS) {
|
|
44
|
+
const matched = patterns.some((pattern) => {
|
|
45
|
+
// Global and sticky patterns are stateful; test from the start, then leave the caller's regex
|
|
46
|
+
// as it was found.
|
|
47
|
+
const previous = pattern.lastIndex;
|
|
48
|
+
pattern.lastIndex = 0;
|
|
49
|
+
const result = pattern.test(message);
|
|
50
|
+
pattern.lastIndex = previous;
|
|
51
|
+
return result;
|
|
52
|
+
});
|
|
53
|
+
return matched ? { code: 'PROMPT_INJECTION' } : null;
|
|
54
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type EmbeddingOptions } from './bedrock-embeddings.mts';
|
|
2
|
+
/**
|
|
3
|
+
* Nova accepts inline media up to 25 MB after Base64 encoding; 18,000,000 raw bytes encode to
|
|
4
|
+
* 24,000,000, leaving room for framing; callers that enforce a smaller
|
|
5
|
+
* decoded bound (e.g. after converting images) pass it as `maxImageBytes`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_MAX_IMAGE_EMBEDDING_BYTES = 18000000;
|
|
8
|
+
export type BatchSizeOptions = EmbeddingOptions & {
|
|
9
|
+
/** Decoded image bytes bound used to budget the worst-case record. */
|
|
10
|
+
maxImageBytes?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Longest `recordId` in UTF-16 code units (default 128). Budgeted at JSON's worst case of six
|
|
13
|
+
* bytes per unit, since escapes can expand the encoded line.
|
|
14
|
+
*/
|
|
15
|
+
maxRecordIdLength?: number;
|
|
16
|
+
};
|
|
17
|
+
/** One JSONL line of a batch input file. `recordId` is opaque and comes back on the result line. */
|
|
18
|
+
export declare function createEmbeddingRecordLine(recordId: string, modelInput: Record<string, unknown>): string;
|
|
19
|
+
export declare function createEmbeddingTextRecordLine(record: {
|
|
20
|
+
recordId: string;
|
|
21
|
+
content: string;
|
|
22
|
+
}, options?: EmbeddingOptions): string;
|
|
23
|
+
export declare function createEmbeddingImageRecordLine(record: {
|
|
24
|
+
recordId: string;
|
|
25
|
+
format: 'jpeg' | 'png' | 'webp' | 'gif';
|
|
26
|
+
bytes: string;
|
|
27
|
+
}, options?: EmbeddingOptions): string;
|
|
28
|
+
/** Lower bound on a batch file for `records` images: worst-case payload plus actual framing. */
|
|
29
|
+
export declare function minimumImageBatchSizeMB(records: number, options?: BatchSizeOptions): number;
|
|
30
|
+
/** Same for text; JSON control-character escapes cost up to six bytes per UTF-16 code unit. */
|
|
31
|
+
export declare function minimumTextBatchSizeMB(records: number, options?: BatchSizeOptions): number;
|
|
32
|
+
/** One line of a batch output file. */
|
|
33
|
+
export type BatchResultLine = {
|
|
34
|
+
recordId: string;
|
|
35
|
+
error?: unknown;
|
|
36
|
+
modelOutput?: {
|
|
37
|
+
embedding?: number[];
|
|
38
|
+
embeddings?: Array<{
|
|
39
|
+
embedding: number[];
|
|
40
|
+
} | number[]>;
|
|
41
|
+
inputTokenCount?: number;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
export type BatchEmbeddingResult = {
|
|
45
|
+
recordId: string;
|
|
46
|
+
embedding: number[];
|
|
47
|
+
inputTokenCount: number | null;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The vector of a result line, or null for errored lines and lines without one. Decoding the
|
|
51
|
+
* opaque `recordId` and validating the vector's dimension stay with the application.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseBatchResult(line: BatchResultLine): BatchEmbeddingResult | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { buildEmbeddingModelInput, extractEmbedding, NOVA_EMBEDDING_DEFAULTS, } from './bedrock-embeddings.mjs';
|
|
2
|
+
/**
|
|
3
|
+
* Nova accepts inline media up to 25 MB after Base64 encoding; 18,000,000 raw bytes encode to
|
|
4
|
+
* 24,000,000, leaving room for framing; callers that enforce a smaller
|
|
5
|
+
* decoded bound (e.g. after converting images) pass it as `maxImageBytes`.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_MAX_IMAGE_EMBEDDING_BYTES = 18_000_000;
|
|
8
|
+
const DEFAULT_MAX_RECORD_ID_LENGTH = 128;
|
|
9
|
+
/** A control character JSON-escapes to six bytes (`\u0001`), the worst case per code unit. */
|
|
10
|
+
const WORST_CASE_ID_UNIT = '\u0001';
|
|
11
|
+
/** One JSONL line of a batch input file. `recordId` is opaque and comes back on the result line. */
|
|
12
|
+
export function createEmbeddingRecordLine(recordId, modelInput) {
|
|
13
|
+
return JSON.stringify({ recordId, modelInput });
|
|
14
|
+
}
|
|
15
|
+
export function createEmbeddingTextRecordLine(record, options = {}) {
|
|
16
|
+
return createEmbeddingRecordLine(record.recordId, buildEmbeddingModelInput({ text: record.content }, options));
|
|
17
|
+
}
|
|
18
|
+
export function createEmbeddingImageRecordLine(record, options = {}) {
|
|
19
|
+
return createEmbeddingRecordLine(record.recordId, buildEmbeddingModelInput({ image: { format: record.format, bytes: record.bytes } }, options));
|
|
20
|
+
}
|
|
21
|
+
function framingBytes(line) {
|
|
22
|
+
return Buffer.byteLength(line) + 1;
|
|
23
|
+
}
|
|
24
|
+
/** Lower bound on a batch file for `records` images: worst-case payload plus actual framing. */
|
|
25
|
+
export function minimumImageBatchSizeMB(records, options = {}) {
|
|
26
|
+
const recordId = WORST_CASE_ID_UNIT.repeat(options.maxRecordIdLength ?? DEFAULT_MAX_RECORD_ID_LENGTH);
|
|
27
|
+
const framing = framingBytes(createEmbeddingImageRecordLine({ recordId, format: 'jpeg', bytes: '' }, options));
|
|
28
|
+
const maxBytes = options.maxImageBytes ?? DEFAULT_MAX_IMAGE_EMBEDDING_BYTES;
|
|
29
|
+
return (records * (Math.ceil(maxBytes / 3) * 4 + framing)) / 1024 / 1024;
|
|
30
|
+
}
|
|
31
|
+
/** Same for text; JSON control-character escapes cost up to six bytes per UTF-16 code unit. */
|
|
32
|
+
export function minimumTextBatchSizeMB(records, options = {}) {
|
|
33
|
+
const recordId = WORST_CASE_ID_UNIT.repeat(options.maxRecordIdLength ?? DEFAULT_MAX_RECORD_ID_LENGTH);
|
|
34
|
+
const maxTextLength = options.maxTextLength ?? NOVA_EMBEDDING_DEFAULTS.maxTextLength;
|
|
35
|
+
const framing = framingBytes(createEmbeddingTextRecordLine({ recordId, content: '' }, options));
|
|
36
|
+
return (records * (maxTextLength * 6 + framing)) / 1024 / 1024;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The vector of a result line, or null for errored lines and lines without one. Decoding the
|
|
40
|
+
* opaque `recordId` and validating the vector's dimension stay with the application.
|
|
41
|
+
*/
|
|
42
|
+
export function parseBatchResult(line) {
|
|
43
|
+
const embedding = line.error ? undefined : extractEmbedding(line.modelOutput);
|
|
44
|
+
if (!embedding)
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
recordId: line.recordId,
|
|
48
|
+
embedding,
|
|
49
|
+
inputTokenCount: line.modelOutput?.inputTokenCount ?? null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Defaults for Amazon Nova multimodal embeddings; every field can be overridden. */
|
|
2
|
+
export declare const NOVA_EMBEDDING_DEFAULTS: {
|
|
3
|
+
readonly modelId: 'amazon.nova-2-multimodal-embeddings-v1:0';
|
|
4
|
+
readonly dimension: 1024;
|
|
5
|
+
/** Nova's inline `text.value` cap (UTF-16 code units). */
|
|
6
|
+
readonly maxTextLength: 8192;
|
|
7
|
+
/** Nova documents no input token count; this approximate estimate is for analytics only. */
|
|
8
|
+
readonly charactersPerToken: 4;
|
|
9
|
+
};
|
|
10
|
+
export type EmbeddingOptions = {
|
|
11
|
+
modelId?: string;
|
|
12
|
+
dimension?: number;
|
|
13
|
+
maxTextLength?: number;
|
|
14
|
+
/** `embeddingPurpose`: `GENERIC_INDEX` (default) for stored content, a retrieval purpose for queries. */
|
|
15
|
+
purpose?: string;
|
|
16
|
+
/** Nova image `detailLevel` (e.g. `STANDARD_IMAGE` or `DOCUMENT_IMAGE`); omitted by default. */
|
|
17
|
+
imageDetailLevel?: string;
|
|
18
|
+
};
|
|
19
|
+
/** The `InvokeModel` input; the app wraps it in its own command and client. */
|
|
20
|
+
export type InvokeEmbeddingInput = {
|
|
21
|
+
modelId: string;
|
|
22
|
+
contentType: 'application/json';
|
|
23
|
+
accept: 'application/json';
|
|
24
|
+
body: string;
|
|
25
|
+
};
|
|
26
|
+
type EmbeddingEntry = {
|
|
27
|
+
embedding: number[];
|
|
28
|
+
} | number[];
|
|
29
|
+
type EmbeddingResponseBody = {
|
|
30
|
+
embedding?: number[];
|
|
31
|
+
embeddings?: EmbeddingEntry[];
|
|
32
|
+
};
|
|
33
|
+
export declare class InvalidEmbeddingError extends Error {
|
|
34
|
+
constructor(message: string, options?: ErrorOptions);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Slices to the limit. When the cut falls inside a word, drops a trailing high surrogate and backs
|
|
38
|
+
* up to the last whitespace so the word is not cut; a cut that already ends a word is kept. Falls
|
|
39
|
+
* back to the hard slice when the head has no whitespace.
|
|
40
|
+
*/
|
|
41
|
+
export declare function truncateEmbeddingText(text: string, maxLength?: number): string;
|
|
42
|
+
export declare function estimateEmbeddingTokens(text: string, charactersPerToken?: number): number;
|
|
43
|
+
/** The model input for one text or image, shared by the single and batch record formats. */
|
|
44
|
+
export declare function buildEmbeddingModelInput(content: {
|
|
45
|
+
text: string;
|
|
46
|
+
} | {
|
|
47
|
+
image: {
|
|
48
|
+
format: 'jpeg' | 'png' | 'webp' | 'gif';
|
|
49
|
+
bytes: string;
|
|
50
|
+
};
|
|
51
|
+
}, options?: EmbeddingOptions): Record<string, unknown>;
|
|
52
|
+
export declare function buildInvokeEmbeddingInput(text: string, options?: EmbeddingOptions): InvokeEmbeddingInput;
|
|
53
|
+
/** The first embedding of a response or batch `modelOutput`, in either documented shape. */
|
|
54
|
+
export declare function extractEmbedding(body: EmbeddingResponseBody | undefined): number[] | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Parses an `InvokeModel` response body into a dense vector of `dimension` finite numbers; throws
|
|
57
|
+
* `InvalidEmbeddingError` when the body is empty or holds no valid vector.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseEmbeddingResponseBody(body: Uint8Array | undefined, dimension?: number): number[];
|
|
60
|
+
/** Narrows to a finite-number vector of exactly `dimension` entries or throws. */
|
|
61
|
+
export declare function requireDenseEmbedding(value: unknown, dimension?: number): number[];
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Defaults for Amazon Nova multimodal embeddings; every field can be overridden. */
|
|
2
|
+
export const NOVA_EMBEDDING_DEFAULTS = {
|
|
3
|
+
modelId: 'amazon.nova-2-multimodal-embeddings-v1:0',
|
|
4
|
+
dimension: 1024,
|
|
5
|
+
/** Nova's inline `text.value` cap (UTF-16 code units). */
|
|
6
|
+
maxTextLength: 8_192,
|
|
7
|
+
/** Nova documents no input token count; this approximate estimate is for analytics only. */
|
|
8
|
+
charactersPerToken: 4,
|
|
9
|
+
};
|
|
10
|
+
export class InvalidEmbeddingError extends Error {
|
|
11
|
+
constructor(message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.name = 'InvalidEmbeddingError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Slices to the limit. When the cut falls inside a word, drops a trailing high surrogate and backs
|
|
18
|
+
* up to the last whitespace so the word is not cut; a cut that already ends a word is kept. Falls
|
|
19
|
+
* back to the hard slice when the head has no whitespace.
|
|
20
|
+
*/
|
|
21
|
+
export function truncateEmbeddingText(text, maxLength = NOVA_EMBEDDING_DEFAULTS.maxTextLength) {
|
|
22
|
+
if (text.length <= maxLength)
|
|
23
|
+
return text;
|
|
24
|
+
let sliced = text.slice(0, maxLength);
|
|
25
|
+
if (/\s/.test(text.charAt(maxLength)))
|
|
26
|
+
return sliced;
|
|
27
|
+
const last = sliced.charCodeAt(sliced.length - 1);
|
|
28
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
29
|
+
sliced = sliced.slice(0, -1);
|
|
30
|
+
const lastWhitespace = sliced.search(/\s\S*$/);
|
|
31
|
+
return lastWhitespace > 0 ? sliced.slice(0, lastWhitespace) : sliced;
|
|
32
|
+
}
|
|
33
|
+
export function estimateEmbeddingTokens(text, charactersPerToken = NOVA_EMBEDDING_DEFAULTS.charactersPerToken) {
|
|
34
|
+
return Math.ceil(text.length / charactersPerToken);
|
|
35
|
+
}
|
|
36
|
+
/** The model input for one text or image, shared by the single and batch record formats. */
|
|
37
|
+
export function buildEmbeddingModelInput(content, options = {}) {
|
|
38
|
+
const dimension = options.dimension ?? NOVA_EMBEDDING_DEFAULTS.dimension;
|
|
39
|
+
const common = {
|
|
40
|
+
embeddingPurpose: options.purpose ?? 'GENERIC_INDEX',
|
|
41
|
+
embeddingDimension: dimension,
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
taskType: 'SINGLE_EMBEDDING',
|
|
45
|
+
singleEmbeddingParams: 'text' in content
|
|
46
|
+
? {
|
|
47
|
+
...common,
|
|
48
|
+
text: {
|
|
49
|
+
truncationMode: 'END',
|
|
50
|
+
value: truncateEmbeddingText(content.text, options.maxTextLength),
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
: {
|
|
54
|
+
...common,
|
|
55
|
+
image: {
|
|
56
|
+
format: content.image.format,
|
|
57
|
+
source: { bytes: content.image.bytes },
|
|
58
|
+
...(options.imageDetailLevel ? { detailLevel: options.imageDetailLevel } : {}),
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function buildInvokeEmbeddingInput(text, options = {}) {
|
|
64
|
+
return {
|
|
65
|
+
modelId: options.modelId ?? NOVA_EMBEDDING_DEFAULTS.modelId,
|
|
66
|
+
contentType: 'application/json',
|
|
67
|
+
accept: 'application/json',
|
|
68
|
+
body: JSON.stringify(buildEmbeddingModelInput({ text }, options)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** The first embedding of a response or batch `modelOutput`, in either documented shape. */
|
|
72
|
+
export function extractEmbedding(body) {
|
|
73
|
+
if (body?.embedding)
|
|
74
|
+
return body.embedding;
|
|
75
|
+
const first = body?.embeddings?.[0];
|
|
76
|
+
return Array.isArray(first) ? first : first?.embedding;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Parses an `InvokeModel` response body into a dense vector of `dimension` finite numbers; throws
|
|
80
|
+
* `InvalidEmbeddingError` when the body is empty or holds no valid vector.
|
|
81
|
+
*/
|
|
82
|
+
export function parseEmbeddingResponseBody(body, dimension = NOVA_EMBEDDING_DEFAULTS.dimension) {
|
|
83
|
+
if (!body)
|
|
84
|
+
throw new InvalidEmbeddingError('Bedrock embedding response body is empty');
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(new TextDecoder().decode(body));
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
throw new InvalidEmbeddingError('Bedrock embedding response is not valid JSON', { cause: err });
|
|
91
|
+
}
|
|
92
|
+
const embedding = extractEmbedding(parsed);
|
|
93
|
+
if (!embedding) {
|
|
94
|
+
throw new InvalidEmbeddingError('Bedrock embedding response did not include an embedding');
|
|
95
|
+
}
|
|
96
|
+
return requireDenseEmbedding(embedding, dimension);
|
|
97
|
+
}
|
|
98
|
+
/** Narrows to a finite-number vector of exactly `dimension` entries or throws. */
|
|
99
|
+
export function requireDenseEmbedding(value, dimension = NOVA_EMBEDDING_DEFAULTS.dimension) {
|
|
100
|
+
if (!Array.isArray(value) ||
|
|
101
|
+
value.length !== dimension ||
|
|
102
|
+
value.some((element) => typeof element !== 'number' || !Number.isFinite(element))) {
|
|
103
|
+
throw new InvalidEmbeddingError('Bedrock embedding is not a dense vector');
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { JsonLd, JsonLdContext } from './json-ld.mts';
|
|
2
|
+
export declare function createProfilePageJsonLd(ctx: JsonLdContext, input: {
|
|
3
|
+
username: string;
|
|
4
|
+
displayName?: string | null;
|
|
5
|
+
path: string;
|
|
6
|
+
identifier: string;
|
|
7
|
+
imagePath?: string | null;
|
|
8
|
+
dateCreated?: string;
|
|
9
|
+
sameAs?: string[];
|
|
10
|
+
}): JsonLd;
|
|
11
|
+
/** Returns the first mapped schema.org type for the slugs, else `fallback`. */
|
|
12
|
+
export declare function resolveSchemaOrgType(slugs: readonly string[], mapping: Readonly<Record<string, string>>, fallback?: string): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function createProfilePageJsonLd(ctx, input) {
|
|
2
|
+
const mainEntity = {
|
|
3
|
+
'@type': 'Person',
|
|
4
|
+
name: input.displayName || input.username,
|
|
5
|
+
identifier: input.identifier,
|
|
6
|
+
url: ctx.absoluteUrl(input.path),
|
|
7
|
+
};
|
|
8
|
+
if (input.displayName && input.displayName !== input.username) {
|
|
9
|
+
mainEntity.alternateName = input.username;
|
|
10
|
+
}
|
|
11
|
+
if (input.imagePath)
|
|
12
|
+
mainEntity.image = ctx.absoluteUrl(input.imagePath);
|
|
13
|
+
if (input.sameAs?.length)
|
|
14
|
+
mainEntity.sameAs = input.sameAs;
|
|
15
|
+
return {
|
|
16
|
+
'@context': 'https://schema.org',
|
|
17
|
+
'@type': 'ProfilePage',
|
|
18
|
+
url: ctx.absoluteUrl(input.path),
|
|
19
|
+
...(input.dateCreated ? { dateCreated: input.dateCreated } : {}),
|
|
20
|
+
mainEntity,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Returns the first mapped schema.org type for the slugs, else `fallback`. */
|
|
24
|
+
export function resolveSchemaOrgType(slugs, mapping, fallback = 'Thing') {
|
|
25
|
+
for (const slug of slugs) {
|
|
26
|
+
const type = Object.hasOwn(mapping, slug) ? mapping[slug] : undefined;
|
|
27
|
+
if (type)
|
|
28
|
+
return type;
|
|
29
|
+
}
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type JsonLd = Record<string, unknown>;
|
|
2
|
+
export interface JsonLdContext {
|
|
3
|
+
/** Resolves a site path to an absolute URL; injected so origin policy stays in the application. */
|
|
4
|
+
absoluteUrl: (path: string) => string;
|
|
5
|
+
}
|
|
6
|
+
export interface JsonLdSite extends JsonLdContext {
|
|
7
|
+
siteName: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ItemListInput {
|
|
10
|
+
name: string;
|
|
11
|
+
url: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
image?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const SCHEMA_ORG_CONTEXT = "https://schema.org";
|
|
16
|
+
/** Escapes characters that could terminate an inline `<script>` or break JS parsing. */
|
|
17
|
+
export declare function escapeInlineScriptJson(json: string): string;
|
|
18
|
+
export declare function serializeJsonLd(data: JsonLd): string;
|
|
19
|
+
export declare function createOrganizationJsonLd(site: JsonLdSite, logoPath: string): JsonLd;
|
|
20
|
+
export declare function createWebSiteJsonLd(site: JsonLdSite): JsonLd;
|
|
21
|
+
export declare function createSiteNavigationJsonLd(ctx: JsonLdContext, items: {
|
|
22
|
+
name: string;
|
|
23
|
+
path: string;
|
|
24
|
+
}[], name?: string): JsonLd;
|
|
25
|
+
export declare function createCollectionPageJsonLd(site: JsonLdSite, input: {
|
|
26
|
+
title: string;
|
|
27
|
+
description: string;
|
|
28
|
+
path: string;
|
|
29
|
+
}): JsonLd;
|
|
30
|
+
export declare function createThingJsonLd(ctx: JsonLdContext, input: {
|
|
31
|
+
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
path: string;
|
|
34
|
+
schemaOrgType?: string;
|
|
35
|
+
}): JsonLd;
|
|
36
|
+
export declare function createWebPageJsonLd(site: JsonLdSite, input: {
|
|
37
|
+
name: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
path: string;
|
|
40
|
+
}): JsonLd;
|
|
41
|
+
interface ComparedThing {
|
|
42
|
+
name: string;
|
|
43
|
+
path: string;
|
|
44
|
+
schemaOrgType?: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function createComparisonPageJsonLd(site: JsonLdSite, input: {
|
|
47
|
+
a: ComparedThing;
|
|
48
|
+
b: ComparedThing;
|
|
49
|
+
path: string;
|
|
50
|
+
}): JsonLd;
|
|
51
|
+
export declare function createItemListJsonLd(ctx: JsonLdContext, items: ItemListInput[], listName: string): JsonLd;
|
|
52
|
+
/** Breadcrumb names are resolved by the caller (translation stays in the application). */
|
|
53
|
+
export declare function createBreadcrumbJsonLd(ctx: JsonLdContext, items: {
|
|
54
|
+
name: string;
|
|
55
|
+
path: string;
|
|
56
|
+
}[]): JsonLd;
|
|
57
|
+
export { createProfilePageJsonLd, resolveSchemaOrgType } from './json-ld-profile.mts';
|
package/dist/json-ld.mjs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export const SCHEMA_ORG_CONTEXT = 'https://schema.org';
|
|
2
|
+
const CONTEXT = SCHEMA_ORG_CONTEXT;
|
|
3
|
+
/** Escapes characters that could terminate an inline `<script>` or break JS parsing. */
|
|
4
|
+
export function escapeInlineScriptJson(json) {
|
|
5
|
+
return json
|
|
6
|
+
.replace(/</g, '\\u003c')
|
|
7
|
+
.replace(/>/g, '\\u003e')
|
|
8
|
+
.replace(/&/g, '\\u0026')
|
|
9
|
+
.replace(new RegExp('[\\u2028\\u2029]', 'g'), (char) => `\\u${char.charCodeAt(0).toString(16)}`);
|
|
10
|
+
}
|
|
11
|
+
export function serializeJsonLd(data) {
|
|
12
|
+
return escapeInlineScriptJson(JSON.stringify(data));
|
|
13
|
+
}
|
|
14
|
+
function webSiteRef(site) {
|
|
15
|
+
return { '@type': 'WebSite', name: site.siteName, url: site.absoluteUrl('/') };
|
|
16
|
+
}
|
|
17
|
+
function optional(key, value) {
|
|
18
|
+
return value ? { [key]: value } : {};
|
|
19
|
+
}
|
|
20
|
+
export function createOrganizationJsonLd(site, logoPath) {
|
|
21
|
+
return {
|
|
22
|
+
'@context': CONTEXT,
|
|
23
|
+
'@type': 'Organization',
|
|
24
|
+
name: site.siteName,
|
|
25
|
+
url: site.absoluteUrl('/'),
|
|
26
|
+
logo: site.absoluteUrl(logoPath),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function createWebSiteJsonLd(site) {
|
|
30
|
+
return { '@context': CONTEXT, ...webSiteRef(site) };
|
|
31
|
+
}
|
|
32
|
+
export function createSiteNavigationJsonLd(ctx, items, name = 'Primary navigation') {
|
|
33
|
+
return {
|
|
34
|
+
'@context': CONTEXT,
|
|
35
|
+
'@type': 'SiteNavigationElement',
|
|
36
|
+
name,
|
|
37
|
+
hasPart: items.map((item) => ({
|
|
38
|
+
'@type': 'WebPage',
|
|
39
|
+
name: item.name,
|
|
40
|
+
url: ctx.absoluteUrl(item.path),
|
|
41
|
+
})),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function createCollectionPageJsonLd(site, input) {
|
|
45
|
+
return {
|
|
46
|
+
'@context': CONTEXT,
|
|
47
|
+
'@type': 'CollectionPage',
|
|
48
|
+
name: input.title,
|
|
49
|
+
description: input.description,
|
|
50
|
+
url: site.absoluteUrl(input.path),
|
|
51
|
+
isPartOf: webSiteRef(site),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function createThingJsonLd(ctx, input) {
|
|
55
|
+
return {
|
|
56
|
+
'@context': CONTEXT,
|
|
57
|
+
'@type': input.schemaOrgType ?? 'Thing',
|
|
58
|
+
name: input.name,
|
|
59
|
+
...optional('description', input.description),
|
|
60
|
+
url: ctx.absoluteUrl(input.path),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function createWebPageJsonLd(site, input) {
|
|
64
|
+
return {
|
|
65
|
+
'@context': CONTEXT,
|
|
66
|
+
'@type': 'WebPage',
|
|
67
|
+
name: input.name,
|
|
68
|
+
...optional('description', input.description),
|
|
69
|
+
url: site.absoluteUrl(input.path),
|
|
70
|
+
isPartOf: webSiteRef(site),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function createComparisonPageJsonLd(site, input) {
|
|
74
|
+
const about = (thing) => ({
|
|
75
|
+
'@type': thing.schemaOrgType ?? 'Thing',
|
|
76
|
+
name: thing.name,
|
|
77
|
+
url: site.absoluteUrl(thing.path),
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
'@context': CONTEXT,
|
|
81
|
+
'@type': 'WebPage',
|
|
82
|
+
name: `${input.a.name} vs ${input.b.name}`,
|
|
83
|
+
url: site.absoluteUrl(input.path),
|
|
84
|
+
about: [about(input.a), about(input.b)],
|
|
85
|
+
isPartOf: webSiteRef(site),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export function createItemListJsonLd(ctx, items, listName) {
|
|
89
|
+
return {
|
|
90
|
+
'@context': CONTEXT,
|
|
91
|
+
'@type': 'ItemList',
|
|
92
|
+
name: listName,
|
|
93
|
+
numberOfItems: items.length,
|
|
94
|
+
itemListElement: items.map((item, index) => ({
|
|
95
|
+
'@type': 'ListItem',
|
|
96
|
+
position: index + 1,
|
|
97
|
+
name: item.name,
|
|
98
|
+
url: ctx.absoluteUrl(item.url),
|
|
99
|
+
...optional('description', item.description),
|
|
100
|
+
...(item.image ? { image: ctx.absoluteUrl(item.image) } : {}),
|
|
101
|
+
})),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** Breadcrumb names are resolved by the caller (translation stays in the application). */
|
|
105
|
+
export function createBreadcrumbJsonLd(ctx, items) {
|
|
106
|
+
return {
|
|
107
|
+
'@context': CONTEXT,
|
|
108
|
+
'@type': 'BreadcrumbList',
|
|
109
|
+
itemListElement: items.map((item, index) => ({
|
|
110
|
+
'@type': 'ListItem',
|
|
111
|
+
position: index + 1,
|
|
112
|
+
name: item.name,
|
|
113
|
+
item: ctx.absoluteUrl(item.path),
|
|
114
|
+
})),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
export { createProfilePageJsonLd, resolveSchemaOrgType } from './json-ld-profile.mjs';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const NAMED_ENTITIES = {
|
|
2
|
+
amp: '&',
|
|
3
|
+
lt: '<',
|
|
4
|
+
gt: '>',
|
|
5
|
+
quot: '"',
|
|
6
|
+
apos: "'",
|
|
7
|
+
nbsp: ' ',
|
|
8
|
+
};
|
|
9
|
+
function decodeEntities(value) {
|
|
10
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body) => {
|
|
11
|
+
if (body[0] !== '#')
|
|
12
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? match;
|
|
13
|
+
const code = /^#x/i.test(body) ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
14
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function stripMarkup(value) {
|
|
18
|
+
const withoutTags = value.replace(/<\/?[A-Za-z][^>]*>/g, ' ');
|
|
19
|
+
const withoutLinks = withoutTags.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1');
|
|
20
|
+
const withoutSyntax = withoutLinks.replace(/^\s{0,3}(#{1,6}|>)\s+/gm, '').replace(/[*_`~]+/g, ' ');
|
|
21
|
+
return decodeEntities(withoutSyntax).replace(/\s+/g, ' ').trim();
|
|
22
|
+
}
|
|
23
|
+
/** Plain-text excerpt: strips HTML tags and light Markdown, decodes entities, truncates by code point. */
|
|
24
|
+
export function createExcerpt(value, maxLength = 160) {
|
|
25
|
+
const plainText = stripMarkup(value);
|
|
26
|
+
const points = Array.from(plainText);
|
|
27
|
+
if (points.length <= maxLength)
|
|
28
|
+
return plainText;
|
|
29
|
+
return `${points
|
|
30
|
+
.slice(0, maxLength - 1)
|
|
31
|
+
.join('')
|
|
32
|
+
.trimEnd()}…`;
|
|
33
|
+
}
|