@meoslabs/save-in-meos 0.0.1

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.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * WHY: ImportIntentV1 is the codec SSOT for meos deeplink protocol (MDP).
3
+ * Encode/decode for databox:import must live only in this package.
4
+ * WHAT: Types + encode/decode/buildMeosLink for `databox:import` URLs.
5
+ * HOW: Optimised wire schema (k: u|ut|i|f) → JSON → deflateRaw → base64url.
6
+ * WHERE: @meoslabs/save-in-meos — consumed by widget and meos clients.
7
+ * GUARDED: check-mdp-contract.ts golden fixtures in fixtures/mdp/.
8
+ */
9
+ import pako from "pako";
10
+ /** MDP contract version — semver pinned by golden fixture checkers. */
11
+ export const MDP_CONTRACT_VERSION = "0.0.1";
12
+ /** Canonical meos.do host for import deeplinks. */
13
+ export const MEOS_DO_HOST = "meos.do";
14
+ /** Colon-grammar resource for widget import (not facility bulk import). */
15
+ export const DATABOX_IMPORT_RESOURCE = "databox:import";
16
+ /** Maximum URL length before QR degradation (bytes, conservative for Level-M QR). */
17
+ export const MDP_MAX_QR_URL_LENGTH = 2048;
18
+ export class MdpEncodeError extends Error {
19
+ cause;
20
+ constructor(message, cause) {
21
+ super(`[MdpEncodeError] ${message}`);
22
+ this.cause = cause;
23
+ this.name = "MdpEncodeError";
24
+ }
25
+ }
26
+ export class MdpDecodeError extends Error {
27
+ cause;
28
+ constructor(message, cause) {
29
+ super(`[MdpDecodeError] ${message}`);
30
+ this.cause = cause;
31
+ this.name = "MdpDecodeError";
32
+ }
33
+ }
34
+ const TIER_TO_KIND = {
35
+ REF: "u",
36
+ LITE: "ut",
37
+ IMG: "i",
38
+ FULL: "f",
39
+ };
40
+ const KIND_TO_TIER = {
41
+ u: "REF",
42
+ ut: "LITE",
43
+ i: "IMG",
44
+ f: "FULL",
45
+ };
46
+ function base64ToBase64Url(base64) {
47
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
48
+ }
49
+ function base64UrlToBase64(base64url) {
50
+ let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
51
+ while (base64.length % 4) {
52
+ base64 += "=";
53
+ }
54
+ return base64;
55
+ }
56
+ function bytesToBase64(bytes) {
57
+ if (typeof Buffer !== "undefined") {
58
+ return Buffer.from(bytes).toString("base64");
59
+ }
60
+ let binary = "";
61
+ const chunkSize = 0x8000;
62
+ for (let i = 0; i < bytes.length; i += chunkSize) {
63
+ const chunk = bytes.subarray(i, i + chunkSize);
64
+ let chunkStr = "";
65
+ for (let j = 0; j < chunk.length; j++) {
66
+ chunkStr += String.fromCharCode(chunk[j]);
67
+ }
68
+ binary += chunkStr;
69
+ }
70
+ return btoa(binary);
71
+ }
72
+ function base64ToBytes(base64) {
73
+ if (typeof Buffer !== "undefined") {
74
+ return Uint8Array.from(Buffer.from(base64, "base64"));
75
+ }
76
+ const binaryStr = atob(base64);
77
+ return Uint8Array.from(binaryStr, (c) => c.charCodeAt(0));
78
+ }
79
+ function compressJson(json) {
80
+ try {
81
+ return pako.deflateRaw(json, { level: 9 });
82
+ }
83
+ catch (error) {
84
+ throw new MdpEncodeError("Compression failed", error);
85
+ }
86
+ }
87
+ function decompressToJson(compressed) {
88
+ try {
89
+ return pako.inflateRaw(compressed, { to: "string" });
90
+ }
91
+ catch (error) {
92
+ throw new MdpDecodeError("Decompression failed — invalid or corrupted data", error);
93
+ }
94
+ }
95
+ function encodeBytesToBase64Url(bytes) {
96
+ try {
97
+ return base64ToBase64Url(bytesToBase64(bytes));
98
+ }
99
+ catch (error) {
100
+ throw new MdpEncodeError("Base64 encoding failed", error);
101
+ }
102
+ }
103
+ function decodeBase64UrlToBytes(encoded) {
104
+ try {
105
+ return base64ToBytes(base64UrlToBase64(encoded));
106
+ }
107
+ catch (error) {
108
+ throw new MdpDecodeError("Base64url decoding failed", error);
109
+ }
110
+ }
111
+ function assertUrl(value, field) {
112
+ try {
113
+ const parsed = new URL(value);
114
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
115
+ throw new MdpEncodeError(`${field} must be an http(s) URL`);
116
+ }
117
+ }
118
+ catch (error) {
119
+ if (error instanceof MdpEncodeError)
120
+ throw error;
121
+ throw new MdpEncodeError(`${field} must be a valid URL`);
122
+ }
123
+ }
124
+ function validateIntent(intent) {
125
+ if (intent.v !== 1) {
126
+ throw new MdpEncodeError(`Unsupported schema version: ${intent.v}`);
127
+ }
128
+ if (!intent.u || typeof intent.u !== "string") {
129
+ throw new MdpEncodeError("Intent requires canonical URL (u)");
130
+ }
131
+ assertUrl(intent.u, "u");
132
+ switch (intent.tier) {
133
+ case "REF":
134
+ break;
135
+ case "LITE":
136
+ if (!intent.t || typeof intent.t !== "string" || intent.t.trim().length === 0) {
137
+ throw new MdpEncodeError("LITE tier requires quoted text (t)");
138
+ }
139
+ break;
140
+ case "IMG":
141
+ if (!intent.images || intent.images.length === 0) {
142
+ throw new MdpEncodeError("IMG tier requires at least one image URL");
143
+ }
144
+ for (const imageUrl of intent.images) {
145
+ assertUrl(imageUrl, "images[]");
146
+ }
147
+ break;
148
+ case "FULL":
149
+ if (!intent.blocks || intent.blocks.length === 0) {
150
+ throw new MdpEncodeError("FULL tier requires at least one block");
151
+ }
152
+ break;
153
+ default:
154
+ throw new MdpEncodeError(`Unknown tier: ${String(intent.tier)}`);
155
+ }
156
+ }
157
+ function intentToWire(intent) {
158
+ const k = TIER_TO_KIND[intent.tier];
159
+ const wire = { k, u: intent.u };
160
+ if (intent.tier === "LITE" && intent.t) {
161
+ wire.t = intent.t;
162
+ }
163
+ if (intent.tier === "IMG") {
164
+ if (intent.t)
165
+ wire.t = intent.t;
166
+ wire.imgs = intent.images;
167
+ }
168
+ if (intent.tier === "FULL" && intent.blocks) {
169
+ wire.blocks = intent.blocks;
170
+ }
171
+ return wire;
172
+ }
173
+ function wireToIntent(wire) {
174
+ if (!wire || typeof wire !== "object") {
175
+ throw new MdpDecodeError("Invalid schema: not an object");
176
+ }
177
+ if (!wire.k || !(wire.k in KIND_TO_TIER)) {
178
+ throw new MdpDecodeError(`Invalid schema: unknown kind "${String(wire.k)}"`);
179
+ }
180
+ if (!wire.u || typeof wire.u !== "string") {
181
+ throw new MdpDecodeError("Invalid schema: missing canonical URL (u)");
182
+ }
183
+ try {
184
+ const parsed = new URL(wire.u);
185
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
186
+ throw new MdpDecodeError("Invalid schema: u must be an http(s) URL");
187
+ }
188
+ }
189
+ catch (error) {
190
+ if (error instanceof MdpDecodeError)
191
+ throw error;
192
+ throw new MdpDecodeError("Invalid schema: u must be a valid URL");
193
+ }
194
+ const tier = KIND_TO_TIER[wire.k];
195
+ const intent = { v: 1, tier, u: wire.u };
196
+ switch (tier) {
197
+ case "LITE":
198
+ if (!wire.t || typeof wire.t !== "string" || wire.t.trim().length === 0) {
199
+ throw new MdpDecodeError("LITE tier requires quoted text (t)");
200
+ }
201
+ intent.t = wire.t;
202
+ break;
203
+ case "IMG":
204
+ if (!wire.imgs || wire.imgs.length === 0) {
205
+ throw new MdpDecodeError("IMG tier requires image URLs (imgs)");
206
+ }
207
+ for (const imageUrl of wire.imgs) {
208
+ try {
209
+ const parsed = new URL(imageUrl);
210
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
211
+ throw new MdpDecodeError("Invalid schema: imgs[] must be http(s) URLs");
212
+ }
213
+ }
214
+ catch (error) {
215
+ if (error instanceof MdpDecodeError)
216
+ throw error;
217
+ throw new MdpDecodeError("Invalid schema: imgs[] must be valid URLs");
218
+ }
219
+ }
220
+ if (wire.t)
221
+ intent.t = wire.t;
222
+ intent.images = wire.imgs;
223
+ break;
224
+ case "FULL":
225
+ if (!wire.blocks || wire.blocks.length === 0) {
226
+ throw new MdpDecodeError("FULL tier requires blocks");
227
+ }
228
+ intent.blocks = wire.blocks;
229
+ break;
230
+ default:
231
+ break;
232
+ }
233
+ return intent;
234
+ }
235
+ /**
236
+ * Select the minimum encoding tier for the given content.
237
+ * REF — URL only; LITE — URL + distinct text; IMG — image URLs; FULL — blocks.
238
+ */
239
+ export function selectImportTier(input) {
240
+ if (input.blocks && input.blocks.length > 0)
241
+ return "FULL";
242
+ if (input.images && input.images.length > 0)
243
+ return "IMG";
244
+ if (input.t && input.t.trim().length > 0 && input.t !== input.u)
245
+ return "LITE";
246
+ return "REF";
247
+ }
248
+ /** Build a typed ImportIntentV1 from loose widget input. */
249
+ export function buildImportIntentV1(input) {
250
+ const tier = selectImportTier(input);
251
+ const intent = { v: 1, tier, u: input.u };
252
+ if (tier === "LITE" && input.t)
253
+ intent.t = input.t;
254
+ if (tier === "IMG") {
255
+ if (input.t)
256
+ intent.t = input.t;
257
+ if (input.images)
258
+ intent.images = input.images;
259
+ }
260
+ if (tier === "FULL" && input.blocks)
261
+ intent.blocks = input.blocks;
262
+ return intent;
263
+ }
264
+ /**
265
+ * Encode ImportIntentV1 to a URL-safe payload segment (no host/path).
266
+ * Widget attribution (?w=) is never included in the blob.
267
+ */
268
+ export function encodeImportIntentV1(intent) {
269
+ validateIntent(intent);
270
+ const wire = intentToWire(intent);
271
+ const json = JSON.stringify(wire);
272
+ try {
273
+ const compressed = compressJson(json);
274
+ return encodeBytesToBase64Url(compressed);
275
+ }
276
+ catch (error) {
277
+ if (error instanceof MdpEncodeError)
278
+ throw error;
279
+ throw new MdpEncodeError("Encoding failed", error);
280
+ }
281
+ }
282
+ /**
283
+ * Decode a databox:import payload segment back to ImportIntentV1.
284
+ * Does not parse ?w= — use decodeMeosLink for full URLs.
285
+ */
286
+ export function decodeImportIntentV1(encoded) {
287
+ if (!encoded || typeof encoded !== "string") {
288
+ throw new MdpDecodeError("Encoded segment is empty or invalid");
289
+ }
290
+ try {
291
+ const compressed = decodeBase64UrlToBytes(encoded);
292
+ const json = decompressToJson(compressed);
293
+ const wire = JSON.parse(json);
294
+ return wireToIntent(wire);
295
+ }
296
+ catch (error) {
297
+ if (error instanceof MdpDecodeError)
298
+ throw error;
299
+ if (error instanceof SyntaxError) {
300
+ throw new MdpDecodeError("JSON parsing failed — invalid schema", error);
301
+ }
302
+ throw new MdpDecodeError("Decoding failed", error);
303
+ }
304
+ }
305
+ function assembleMeosUrl(encoded, widgetId) {
306
+ const base = `https://${MEOS_DO_HOST}/${DATABOX_IMPORT_RESOURCE}:${encoded}`;
307
+ if (!widgetId)
308
+ return base;
309
+ return `${base}?w=${encodeURIComponent(widgetId)}`;
310
+ }
311
+ function toRefIntent(intent) {
312
+ return { v: 1, tier: "REF", u: intent.u };
313
+ }
314
+ function toLiteIntent(intent) {
315
+ const quoted = intent.t?.trim();
316
+ if (!quoted) {
317
+ throw new MdpEncodeError("LITE tier requires quoted text (t)");
318
+ }
319
+ return { v: 1, tier: "LITE", u: intent.u, t: quoted };
320
+ }
321
+ /** Step down one tier for QR guard — IMG/FULL try LITE (keep quote) before REF. */
322
+ function degradeIntentForQrGuard(intent) {
323
+ if (intent.tier === "LITE")
324
+ return toRefIntent(intent);
325
+ if (intent.t?.trim())
326
+ return toLiteIntent(intent);
327
+ return toRefIntent(intent);
328
+ }
329
+ /**
330
+ * Build full https://meos.do/databox:import:{encoded}?w={widgetId} URL.
331
+ * Degrades tier (IMG/FULL → LITE → REF) when the URL exceeds MDP_MAX_QR_URL_LENGTH.
332
+ * Widget id is query-only — never embedded in the compressed blob.
333
+ */
334
+ export function buildMeosLink(intent, widgetId, options) {
335
+ validateIntent(intent);
336
+ const maxUrlLength = options?.maxUrlLength ?? MDP_MAX_QR_URL_LENGTH;
337
+ const attribution = widgetId ?? intent.w;
338
+ const blobIntent = {
339
+ v: intent.v,
340
+ tier: intent.tier,
341
+ u: intent.u,
342
+ ...(intent.t !== undefined ? { t: intent.t } : {}),
343
+ ...(intent.images !== undefined ? { images: intent.images } : {}),
344
+ ...(intent.blocks !== undefined ? { blocks: intent.blocks } : {}),
345
+ };
346
+ let workingIntent = blobIntent;
347
+ let url = assembleMeosUrl(encodeImportIntentV1(workingIntent), attribution);
348
+ while (url.length > maxUrlLength && workingIntent.tier !== "REF") {
349
+ workingIntent = degradeIntentForQrGuard(workingIntent);
350
+ url = assembleMeosUrl(encodeImportIntentV1(workingIntent), attribution);
351
+ }
352
+ if (url.length > maxUrlLength) {
353
+ throw new MdpEncodeError(`URL exceeds maxUrlLength (${maxUrlLength}) even at REF tier — shorten canonical URL (u)`);
354
+ }
355
+ return url;
356
+ }
357
+ function extractEncodedSegment(urlOrPath) {
358
+ const trimmed = urlOrPath.trim();
359
+ let pathAndQuery;
360
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
361
+ pathAndQuery = `${new URL(trimmed).pathname}${new URL(trimmed).search}`;
362
+ }
363
+ else if (trimmed.startsWith("/")) {
364
+ pathAndQuery = trimmed;
365
+ }
366
+ else {
367
+ pathAndQuery = `/${trimmed}`;
368
+ }
369
+ const marker = `${DATABOX_IMPORT_RESOURCE}:`;
370
+ const idx = pathAndQuery.indexOf(marker);
371
+ if (idx === -1) {
372
+ throw new MdpDecodeError(`URL must contain resource "${DATABOX_IMPORT_RESOURCE}"`);
373
+ }
374
+ const after = pathAndQuery.slice(idx + marker.length);
375
+ const encoded = after.split("?")[0];
376
+ if (!encoded || encoded.length < 4) {
377
+ throw new MdpDecodeError("Encoded payload segment missing or too short");
378
+ }
379
+ return encoded;
380
+ }
381
+ /**
382
+ * Parse a meos.do import URL into ImportIntentV1.
383
+ * Accepts full URL or path-only `databox:import:…` segments.
384
+ * Widget attribution (?w=) is query-only and not merged into the intent.
385
+ */
386
+ export function decodeMeosLink(url) {
387
+ const trimmed = url.trim();
388
+ const encoded = extractEncodedSegment(trimmed);
389
+ return decodeImportIntentV1(encoded);
390
+ }
391
+ /** Read widget attribution from a meos.do import URL (?w= query param). */
392
+ export function parseWidgetAttribution(url) {
393
+ const trimmed = url.trim();
394
+ if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
395
+ return undefined;
396
+ }
397
+ return new URL(trimmed).searchParams.get("w") ?? undefined;
398
+ }
399
+ //# sourceMappingURL=import-intent-v1.js.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * WHY: Public entry for @meos/save-in-meos — mdp codec + widget initialisers.
3
+ * WHAT: Re-exports ImportIntentV1 types, codec functions, and widget API.
4
+ * WHERE: npm package root (`import from '@meos/save-in-meos'`).
5
+ */
6
+ export { MDP_CONTRACT_VERSION, MEOS_DO_HOST, DATABOX_IMPORT_RESOURCE, MDP_MAX_QR_URL_LENGTH, type ImportIntentTier, type ImportIntentKind, type ImportIntentV1, type ImportIntentInput, type BuildMeosLinkOptions, MdpEncodeError, MdpDecodeError, encodeImportIntentV1, decodeImportIntentV1, buildImportIntentV1, selectImportTier, buildMeosLink, decodeMeosLink, parseWidgetAttribution, } from "./import-intent-v1.js";
7
+ export { initSaveButton, ensureWidgetStyles, buildSaveChipMarkup, MEOS_SAVE_LABEL, MEOS_SAVE_COMPACT_LABEL, MEOS_SAVE_CHIP_CLASS, MEOS_SAVE_ICON_CLASS, MEOS_SAVE_LABEL_CLASS, SAVE_CHIP_HOST_VARS, SAVE_CHIP_PRESETS, resolveChipLabel, type SaveButtonOptions, type SaveChipCustomisation, type SaveChipPreset, type SaveChipTheme, } from "./widget/index.js";
8
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * WHY: Public entry for @meos/save-in-meos — mdp codec + widget initialisers.
3
+ * WHAT: Re-exports ImportIntentV1 types, codec functions, and widget API.
4
+ * WHERE: npm package root (`import from '@meos/save-in-meos'`).
5
+ */
6
+ export { MDP_CONTRACT_VERSION, MEOS_DO_HOST, DATABOX_IMPORT_RESOURCE, MDP_MAX_QR_URL_LENGTH, MdpEncodeError, MdpDecodeError, encodeImportIntentV1, decodeImportIntentV1, buildImportIntentV1, selectImportTier, buildMeosLink, decodeMeosLink, parseWidgetAttribution, } from "./import-intent-v1.js";
7
+ export { initSaveButton, ensureWidgetStyles, buildSaveChipMarkup, MEOS_SAVE_LABEL, MEOS_SAVE_COMPACT_LABEL, MEOS_SAVE_CHIP_CLASS, MEOS_SAVE_ICON_CLASS, MEOS_SAVE_LABEL_CLASS, SAVE_CHIP_HOST_VARS, SAVE_CHIP_PRESETS, resolveChipLabel, } from "./widget/index.js";
8
+ //# sourceMappingURL=index.js.map