@8ux-co/eelzap 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `@8ux-co/eelzap/fields` (`.docs/proposals/zap-cms-v2.md` §2.4, §2.7): typed
3
+ * pick helpers over an item or a document that tag themselves in preview.
4
+ *
5
+ * ```tsx
6
+ * import { fields } from '@8ux-co/eelzap/fields'
7
+ *
8
+ * const f = fields(home) // a HomeDocument from codegen
9
+ * <h1>{f.text('hero_title')}</h1> // carries its stega marker in preview
10
+ * <a href={f.value('cta_url')} {...f.attrs('cta_url')}>…</a>
11
+ * <img {...f.image('hero_image')} /> // src, alt, width, height (+ data-zap in preview)
12
+ *
13
+ * // Numbered slots: `stat_1` … `stat_4`
14
+ * {f.list('stat', 4).map((s) => <p key={s.key}>{s.text()}</p>)}
15
+ * // Slots of several fields: `nav_1_texto` + `nav_1_url` … `nav_5_…`
16
+ * {f.list('nav', 5).filter((nav) => !nav.empty).map((nav) => (
17
+ * <a key={nav.key} href={nav.value('url') ?? '/'} {...nav.attrs('url')}>{nav.text('texto')}</a>
18
+ * ))}
19
+ * ```
20
+ *
21
+ * Keys are typed from the record's `content`, so with codegen types a typo in
22
+ * a field key fails to compile.
23
+ *
24
+ * Outside preview every helper returns plain values and EMPTY attribute
25
+ * objects, so production HTML is byte-identical to reading values directly.
26
+ * In preview (the record's text carries stega, or `{ preview: true }`), text
27
+ * keeps its marker (the overlay finds it with no tag), and the non-text
28
+ * helpers add `data-zap` (a manual tag). Markers never reach an attribute:
29
+ * `value`, `image` and `attrs` clean them.
30
+ */
31
+ /** What `fields` reads: an item (`collection.key` + `slug`) or a document (`key`). */
32
+ interface FieldsRecord {
33
+ content: object;
34
+ slug?: string | null;
35
+ key?: string | null;
36
+ collection?: {
37
+ key?: string | null;
38
+ } | null;
39
+ }
40
+ type Content<R extends FieldsRecord> = R['content'];
41
+ type Key<R extends FieldsRecord> = keyof Content<R> & string;
42
+ /** Field keys whose value is text (string, possibly null). */
43
+ type TextKey<R extends FieldsRecord> = {
44
+ [K in Key<R>]: NonNullable<Content<R>[K]> extends string ? K : never;
45
+ }[Key<R>];
46
+ /**
47
+ * Every numbered prefix in `K`: `stat` for `stat_1`, `nav` for `nav_1_texto`,
48
+ * `hero_stat` for `hero_stat_1`. Walks the key one `_` segment at a time, so a
49
+ * prefix may itself contain `_`.
50
+ */
51
+ type Prefixes<K extends string, Head extends string = ''> = K extends `${infer P}_${infer Rest}` ? (Rest extends `${number}` | `${number}_${string}` ? `${Head}${P}` : never) | Prefixes<Rest, `${Head}${P}_`> : never;
52
+ /** Numbered prefixes of the record (any string for an untyped record). */
53
+ type ListPrefix<R extends FieldsRecord> = string extends Key<R> ? string : Prefixes<Key<R>>;
54
+ /** The suffixes of `prefix_{i}_{suffix}` slots: `texto` and `url` for `nav`. */
55
+ type SlotSuffix<R extends FieldsRecord, P extends string> = string extends Key<R> ? string : Key<R> extends infer K ? K extends `${P}_${number}_${infer S}` ? S : never : never;
56
+ /** A slot field's value type; `unknown` for an untyped record. */
57
+ type SlotValue<R extends FieldsRecord, K> = string extends Key<R> ? unknown : Content<R>[Extract<Key<R>, K>];
58
+ /** `{ 'data-zap': 'blog/hola#cover' }` in preview, `{}` otherwise. */
59
+ type ZapAttrs = {
60
+ 'data-zap'?: string;
61
+ };
62
+ interface FieldImage extends ZapAttrs {
63
+ src: string | undefined;
64
+ alt: string | undefined;
65
+ width: number | undefined;
66
+ height: number | undefined;
67
+ }
68
+ /**
69
+ * One numbered slot of `f.list(prefix, n)`: the same helpers as `fields`,
70
+ * scoped to the slot. With a suffix they read `prefix_{i}_{suffix}`
71
+ * (`nav.text('texto')` is `nav_1_texto`); without one, `prefix_{i}` itself
72
+ * (`stat.text()` is `stat_1`).
73
+ */
74
+ interface FieldSlot<R extends FieldsRecord, P extends string> {
75
+ /** `prefix_{i}`, unique in the list: a React key. */
76
+ readonly key: string;
77
+ /** 1 for the first slot. */
78
+ readonly index: number;
79
+ /** True when every field of the slot is empty (or the slot has no field). */
80
+ readonly empty: boolean;
81
+ /** A text field as delivered, with its stega marker in preview. */
82
+ text(suffix?: SlotSuffix<R, P>): string;
83
+ /** The single field `prefix_{i}`, with any marker removed. */
84
+ value(): SlotValue<R, `${P}_${number}`>;
85
+ /** The slot's field `prefix_{i}_{suffix}`, with any marker removed. */
86
+ value<S extends SlotSuffix<R, P>>(suffix: S): SlotValue<R, `${P}_${number}_${S}`>;
87
+ /** `{ 'data-zap': ref#key }` in preview, `{}` otherwise. */
88
+ attrs(suffix?: SlotSuffix<R, P>): ZapAttrs;
89
+ /** An image or media field as `<img>` attributes, tagged in preview. */
90
+ image(suffix?: SlotSuffix<R, P>): FieldImage;
91
+ }
92
+ interface Fields<R extends FieldsRecord> {
93
+ /** True when the helpers tag (the record was read in preview). */
94
+ readonly preview: boolean;
95
+ /** A text field as delivered: with its stega marker in preview, so the overlay finds it. */
96
+ text<K extends TextKey<R>>(key: K): string;
97
+ /** A field's value with any marker removed: safe in attributes, URLs, comparisons. */
98
+ value<K extends Key<R>>(key: K): Content<R>[K];
99
+ /** `{ 'data-zap': ref#key }` in preview, `{}` otherwise: spread it on the element. */
100
+ attrs(key: Key<R>): ZapAttrs;
101
+ /** An image or media field as `<img>` attributes, tagged in preview. */
102
+ image(key: Key<R>): FieldImage;
103
+ /**
104
+ * Slots `1` … `count` of a numbered prefix, each a `FieldSlot`: fields
105
+ * `prefix_1` … `prefix_n`, or slots of several fields
106
+ * `prefix_{i}_{suffix}` (`nav_1_texto`, `nav_1_url`), read by suffix.
107
+ */
108
+ list<P extends ListPrefix<R>>(prefix: P, count: number): FieldSlot<R, P>[];
109
+ }
110
+ interface FieldsOptions {
111
+ /** Force preview on or off; default: on when any text value carries stega. */
112
+ preview?: boolean;
113
+ }
114
+ declare function fields<R extends FieldsRecord>(record: R, options?: FieldsOptions): Fields<R>;
115
+
116
+ export { type FieldImage, type FieldSlot, type Fields, type FieldsOptions, type FieldsRecord, type ZapAttrs, fields };
package/dist/fields.js ADDED
@@ -0,0 +1,105 @@
1
+ // src/refs.ts
2
+ var ENTRY_REF_RE = /^([a-z0-9]+(?:-[a-z0-9]+)*)\/([a-z0-9]+(?:-[a-z0-9]+)*)$/;
3
+ var DOC_REF_RE = /^doc:([a-z0-9]+(?:-[a-z0-9]+)*)$/;
4
+ var MAX_REF_LENGTH = 300;
5
+ function parseRecordRef(value) {
6
+ if (typeof value !== "string" || value.length > MAX_REF_LENGTH) return null;
7
+ const doc = DOC_REF_RE.exec(value);
8
+ if (doc) return { kind: "document", key: doc[1] };
9
+ const entry = ENTRY_REF_RE.exec(value);
10
+ if (entry) return { kind: "entry", collection: entry[1], slug: entry[2] };
11
+ return null;
12
+ }
13
+ function isRecordRef(value) {
14
+ return parseRecordRef(value) !== null;
15
+ }
16
+ function recordRefsOf(entry) {
17
+ if (!entry) return [];
18
+ if (typeof entry === "string") return isRecordRef(entry) ? [entry] : [];
19
+ const refs = [];
20
+ const add = (ref) => {
21
+ if (isRecordRef(ref) && !refs.includes(ref)) refs.push(ref);
22
+ };
23
+ const collection = entry.collection?.key;
24
+ if (collection) {
25
+ if (entry.slug) add(`${collection}/${entry.slug}`);
26
+ for (const slug of Object.values(entry.localizedSlugs ?? {})) {
27
+ if (slug) add(`${collection}/${slug}`);
28
+ }
29
+ } else if (entry.key) {
30
+ add(`doc:${entry.key}`);
31
+ }
32
+ return refs;
33
+ }
34
+
35
+ // src/stega.ts
36
+ var PREFIX = "\u2060\u200D\u200C\u200C\u200D\u200D\u200C\u2060";
37
+ var MARKER_RE = /⁠‍‌‌‍‍‌⁠(?:[​‌][​‌‍⁠]{3})*/g;
38
+ function hasStega(value) {
39
+ return typeof value === "string" && value.includes(PREFIX);
40
+ }
41
+ function cleanString(text) {
42
+ return text.includes(PREFIX) ? text.replace(MARKER_RE, "") : text;
43
+ }
44
+ function cleanStega(value) {
45
+ if (typeof value === "string") return cleanString(value);
46
+ if (Array.isArray(value)) return value.map((item) => cleanStega(item));
47
+ if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
48
+ const out = {};
49
+ for (const [key, item] of Object.entries(value)) out[key] = cleanStega(item);
50
+ return out;
51
+ }
52
+ return value;
53
+ }
54
+
55
+ // src/fields.ts
56
+ function fields(record, options = {}) {
57
+ const content = record?.content ?? {};
58
+ const preview = options.preview ?? Object.values(content).some((value2) => hasStega(value2));
59
+ const [ref] = recordRefsOf(record);
60
+ const attrs = (key) => preview && ref ? { "data-zap": `${ref}#${key}` } : {};
61
+ const value = (key) => cleanStega(content[key]);
62
+ const text = (key) => {
63
+ const raw = content[key];
64
+ return typeof raw === "string" ? preview ? raw : cleanStega(raw) : "";
65
+ };
66
+ const image = (key) => {
67
+ const media = value(key) ?? {};
68
+ const pick = (name, type) => typeof media[name] === type ? media[name] : void 0;
69
+ return {
70
+ src: pick("url", "string"),
71
+ alt: pick("alt", "string"),
72
+ width: pick("width", "number"),
73
+ height: pick("height", "number"),
74
+ ...attrs(key)
75
+ };
76
+ };
77
+ return {
78
+ preview,
79
+ text,
80
+ value,
81
+ attrs,
82
+ image,
83
+ list: (prefix, count) => Array.from({ length: Math.max(0, Math.floor(count)) }, (_, i) => {
84
+ const key = `${prefix}_${i + 1}`;
85
+ const at = (suffix) => suffix ? `${key}_${suffix}` : key;
86
+ const own = Object.keys(content).filter((k) => k === key || k.startsWith(`${key}_`));
87
+ return {
88
+ key,
89
+ index: i + 1,
90
+ empty: own.every((k) => {
91
+ const v = value(k);
92
+ return v === null || v === void 0 || typeof v === "string" && !v.trim();
93
+ }),
94
+ text: (suffix) => text(at(suffix)),
95
+ value: (suffix) => value(at(suffix)),
96
+ attrs: (suffix) => attrs(at(suffix)),
97
+ image: (suffix) => image(at(suffix))
98
+ };
99
+ })
100
+ };
101
+ }
102
+ export {
103
+ fields
104
+ };
105
+ //# sourceMappingURL=fields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/refs.ts","../src/stega.ts","../src/fields.ts"],"sourcesContent":["/**\n * The record and field grammar of tags (`.docs/proposals/zap-cms-v2.md` §2.4),\n * in a module of its own so the light entries (`./fields`, `./react`, the\n * boot) can name records without pulling in the preview protocol.\n * No imports, no side effects.\n */\n\n/**\n * A record reference in its canonical string form: `collectionKey/slug` for an\n * entry, `doc:documentKey` for a document (the tag grammar, §2.4).\n */\nexport type RecordRef = string\n\n/** Zap's field key pattern. */\nexport const FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,127}$/\n/** Entry and document references; keys and slugs are `[a-z0-9]+(?:-[a-z0-9]+)*`. */\nconst ENTRY_REF_RE = /^([a-z0-9]+(?:-[a-z0-9]+)*)\\/([a-z0-9]+(?:-[a-z0-9]+)*)$/\nconst DOC_REF_RE = /^doc:([a-z0-9]+(?:-[a-z0-9]+)*)$/\nconst MAX_REF_LENGTH = 300\n\nexport type ParsedRecordRef =\n | { kind: 'entry'; collection: string; slug: string }\n | { kind: 'document'; key: string }\n\nexport function parseRecordRef(value: unknown): ParsedRecordRef | null {\n if (typeof value !== 'string' || value.length > MAX_REF_LENGTH) return null\n const doc = DOC_REF_RE.exec(value)\n if (doc) return { kind: 'document', key: doc[1]! }\n const entry = ENTRY_REF_RE.exec(value)\n if (entry) return { kind: 'entry', collection: entry[1]!, slug: entry[2]! }\n return null\n}\n\nexport function isRecordRef(value: unknown): value is RecordRef {\n return parseRecordRef(value) !== null\n}\n\n/** `zpt_` + 32 bytes in base64url, as Zap mints it (zap-cms-v2 §3.5). */\nexport const PREVIEW_TOKEN_RE = /^zpt_[A-Za-z0-9_-]{43}$/\n\n/** A preview token in the shape Zap mints; says nothing about whether it is live. */\nexport function isPreviewTokenShape(value: unknown): value is string {\n return typeof value === 'string' && PREVIEW_TOKEN_RE.test(value)\n}\n\nexport function isFieldKey(value: unknown): value is string {\n return typeof value === 'string' && FIELD_KEY_RE.test(value)\n}\n\n/**\n * What `zapAttrs` and `onValues` accept: a record reference string\n * (`blog/hola`, `doc:home`) or a delivery API entry (an item carries\n * `collection.key` and `slug`, a document `key`).\n */\nexport type EntryLike =\n | RecordRef\n | {\n slug?: string | null\n key?: string | null\n collection?: { key?: string | null } | null\n localizedSlugs?: Record<string, string | null | undefined> | null\n }\n\n/** Every reference an entry answers to (default slug first, then localized ones). */\nexport function recordRefsOf(entry: EntryLike | null | undefined): RecordRef[] {\n if (!entry) return []\n if (typeof entry === 'string') return isRecordRef(entry) ? [entry] : []\n const refs: RecordRef[] = []\n const add = (ref: string) => {\n if (isRecordRef(ref) && !refs.includes(ref)) refs.push(ref)\n }\n const collection = entry.collection?.key\n if (collection) {\n if (entry.slug) add(`${collection}/${entry.slug}`)\n for (const slug of Object.values(entry.localizedSlugs ?? {})) {\n if (slug) add(`${collection}/${slug}`)\n }\n } else if (entry.key) {\n add(`doc:${entry.key}`)\n }\n return refs\n}\n\n/**\n * The tag for one field, as attributes to spread on the element that renders\n * it: `{ 'data-zap': 'blog/hola#title' }`. Empty when the entry or the field\n * key is not one Zap can name, so a bad call never breaks the page.\n */\nexport function zapAttrs(entry: EntryLike, fieldKey: string): { 'data-zap'?: string } {\n const [ref] = recordRefsOf(entry)\n if (!ref || !isFieldKey(fieldKey)) return {}\n return { 'data-zap': `${ref}#${fieldKey}` }\n}\n","/**\n * Stega: invisible field markers on prose values read in preview\n * (`.docs/proposals/zap-cms-v2.md` §2.4, §7.5).\n *\n * Zap appends a marker to TEXT and LONG_TEXT values, and to the last text\n * node of each RICH_TEXT block, ONLY in `preview=1` delivery responses. The\n * preview overlay finds the marker in the page's text nodes and so knows\n * which element renders which field, with no manual tag. Published responses\n * never carry one.\n *\n * ## Format (eelzap stega v1)\n *\n * A marker is the tag the overlay would read from a `data-zap` attribute,\n * `recordRef#fieldKey`, plus `@locale` when the value belongs to a locale:\n *\n * blog-posts/mi-primer-post#title@es\n * doc:home#hero_title@es\n *\n * written as zero-width characters:\n *\n * - four digits, base 4: U+200B (0), U+200C (1), U+200D (2), U+2060 (3);\n * - a prefix of 8 digits, the bytes 0xE5 0xA7 (`3211 2213`);\n * - then every byte of the ASCII payload as 4 digits, most significant first.\n *\n * The payload is printable ASCII (the tag grammar allows nothing else), so\n * every payload byte starts with digit 0 or 1 and the prefix, which starts\n * with 3, can never be read inside a payload. A decoder reads groups of four\n * after the prefix until a group is not a payload byte, then validates the\n * result against the tag grammar; anything that does not parse is ignored. A\n * value never carries two markers (`stegaEncode` refuses to add a second).\n *\n * Why not `@vercel/stega`'s format: it encodes a JSON object (a URL and\n * origin in Sanity's and Vercel's use) and uses U+FEFF, which some tools strip\n * as a byte order mark. Here the decoded marker IS the tag grammar\n * (`data-zap`), so one parser serves both, and U+FEFF is avoided. No\n * dependency either way.\n *\n * Zero dependencies, no side effects: `.` re-exports `cleanStega` and\n * `hasStega`; the encoder is internal (Zap's delivery imports it); the\n * decoder serves `./preview`.\n */\n\nconst DIGITS = ['​', '‌', '‍', '⁠'] as const\nconst PREFIX = '⁠‍‌‌‍‍‌⁠'\n/** Longest payload a marker may carry, in bytes (ref 300 + field 128 + locale). */\nconst MAX_PAYLOAD = 512\n\n/** `recordRef#fieldKey` with an optional `@locale`: the tag grammar (`refs.ts`). */\nconst PAYLOAD_RE =\n /^(doc:[a-z0-9]+(?:-[a-z0-9]+)*|[a-z0-9]+(?:-[a-z0-9]+)*\\/[a-z0-9]+(?:-[a-z0-9]+)*)#([a-z][a-z0-9_]{0,127})(?:@([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8}){0,3}))?$/\n/** The prefix, then whole payload bytes (first digit 0 or 1). */\nconst MARKER_RE = /⁠‍‌‌‍‍‌⁠(?:[​‌][​‌‍⁠]{3})*/g\n\n/** What one marker says: the record, the field, and the locale when known. */\nexport interface StegaInfo {\n /** `collectionKey/slug` or `doc:documentKey`. */\n recordRef: string\n fieldKey: string\n locale?: string | null\n}\n\nexport interface DecodedStega {\n recordRef: string\n fieldKey: string\n locale: string | null\n /** `recordRef#fieldKey`, the `data-zap` tag. */\n tag: string\n}\n\n/** The marker for one field, or `''` when the info does not fit the tag grammar. */\nexport function stegaMarker(info: StegaInfo): string {\n const payload = `${info.recordRef}#${info.fieldKey}${info.locale ? `@${info.locale}` : ''}`\n if (payload.length > MAX_PAYLOAD || !PAYLOAD_RE.test(payload)) return ''\n let out = PREFIX\n for (let i = 0; i < payload.length; i++) {\n const byte = payload.charCodeAt(i)\n out +=\n DIGITS[(byte >> 6) & 3]! +\n DIGITS[(byte >> 4) & 3]! +\n DIGITS[(byte >> 2) & 3]! +\n DIGITS[byte & 3]!\n }\n return out\n}\n\n/**\n * `text` with the field's marker appended. A string that already carries a\n * marker, or info outside the grammar, comes back unchanged.\n */\nexport function stegaEncode(text: string, info: StegaInfo): string {\n if (hasStega(text)) return text\n return text + stegaMarker(info)\n}\n\n/** True when `value` is a string carrying at least one marker. */\nexport function hasStega(value: unknown): boolean {\n return typeof value === 'string' && value.includes(PREFIX)\n}\n\n/** Every valid marker in `text`, in order. Invalid ones are skipped. */\nexport function stegaDecode(text: string): DecodedStega[] {\n if (!text.includes(PREFIX)) return []\n const found: DecodedStega[] = []\n for (const match of text.match(MARKER_RE) ?? []) {\n let payload = ''\n for (let i = PREFIX.length; i + 4 <= match.length; i += 4) {\n let byte = 0\n for (let j = 0; j < 4; j++) byte = byte * 4 + DIGITS.indexOf(match[i + j] as never)\n payload += String.fromCharCode(byte)\n }\n const parsed = PAYLOAD_RE.exec(payload)\n if (!parsed) continue\n found.push({\n recordRef: parsed[1]!,\n fieldKey: parsed[2]!,\n locale: parsed[3] ?? null,\n tag: `${parsed[1]}#${parsed[2]}`,\n })\n }\n return found\n}\n\n/** `text` split into its visible part and the markers it carries, in order. */\nexport function splitStega(text: string): { text: string; markers: string } {\n if (!text.includes(PREFIX)) return { text, markers: '' }\n return { text: text.replace(MARKER_RE, ''), markers: (text.match(MARKER_RE) ?? []).join('') }\n}\n\n/** `text` without any marker. */\nfunction cleanString(text: string): string {\n return text.includes(PREFIX) ? text.replace(MARKER_RE, '') : text\n}\n\n/**\n * `value` with every stega marker removed: a string is cleaned, an array or a\n * plain object is cleaned deeply (into new objects), anything else is\n * returned as is. Use it where a preview value is compared, parsed or put in\n * an attribute, a URL or `<head>`; outside preview it changes nothing.\n */\nexport function cleanStega<T>(value: T): T {\n if (typeof value === 'string') return cleanString(value) as T\n if (Array.isArray(value)) return value.map((item) => cleanStega(item)) as T\n if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {\n const out: Record<string, unknown> = {}\n for (const [key, item] of Object.entries(value)) out[key] = cleanStega(item)\n return out as T\n }\n return value\n}\n","import { recordRefsOf } from './refs'\nimport { cleanStega, hasStega } from './stega'\n\n/**\n * `@8ux-co/eelzap/fields` (`.docs/proposals/zap-cms-v2.md` §2.4, §2.7): typed\n * pick helpers over an item or a document that tag themselves in preview.\n *\n * ```tsx\n * import { fields } from '@8ux-co/eelzap/fields'\n *\n * const f = fields(home) // a HomeDocument from codegen\n * <h1>{f.text('hero_title')}</h1> // carries its stega marker in preview\n * <a href={f.value('cta_url')} {...f.attrs('cta_url')}>…</a>\n * <img {...f.image('hero_image')} /> // src, alt, width, height (+ data-zap in preview)\n *\n * // Numbered slots: `stat_1` … `stat_4`\n * {f.list('stat', 4).map((s) => <p key={s.key}>{s.text()}</p>)}\n * // Slots of several fields: `nav_1_texto` + `nav_1_url` … `nav_5_…`\n * {f.list('nav', 5).filter((nav) => !nav.empty).map((nav) => (\n * <a key={nav.key} href={nav.value('url') ?? '/'} {...nav.attrs('url')}>{nav.text('texto')}</a>\n * ))}\n * ```\n *\n * Keys are typed from the record's `content`, so with codegen types a typo in\n * a field key fails to compile.\n *\n * Outside preview every helper returns plain values and EMPTY attribute\n * objects, so production HTML is byte-identical to reading values directly.\n * In preview (the record's text carries stega, or `{ preview: true }`), text\n * keeps its marker (the overlay finds it with no tag), and the non-text\n * helpers add `data-zap` (a manual tag). Markers never reach an attribute:\n * `value`, `image` and `attrs` clean them.\n */\n\n/** What `fields` reads: an item (`collection.key` + `slug`) or a document (`key`). */\nexport interface FieldsRecord {\n content: object\n slug?: string | null\n key?: string | null\n collection?: { key?: string | null } | null\n}\n\ntype Content<R extends FieldsRecord> = R['content']\ntype Key<R extends FieldsRecord> = keyof Content<R> & string\n/** Field keys whose value is text (string, possibly null). */\ntype TextKey<R extends FieldsRecord> = {\n [K in Key<R>]: NonNullable<Content<R>[K]> extends string ? K : never\n}[Key<R>]\n/**\n * Every numbered prefix in `K`: `stat` for `stat_1`, `nav` for `nav_1_texto`,\n * `hero_stat` for `hero_stat_1`. Walks the key one `_` segment at a time, so a\n * prefix may itself contain `_`.\n */\ntype Prefixes<K extends string, Head extends string = ''> = K extends `${infer P}_${infer Rest}`\n ?\n | (Rest extends `${number}` | `${number}_${string}` ? `${Head}${P}` : never)\n | Prefixes<Rest, `${Head}${P}_`>\n : never\n/** Numbered prefixes of the record (any string for an untyped record). */\ntype ListPrefix<R extends FieldsRecord> = string extends Key<R> ? string : Prefixes<Key<R>>\n/** The suffixes of `prefix_{i}_{suffix}` slots: `texto` and `url` for `nav`. */\ntype SlotSuffix<R extends FieldsRecord, P extends string> =\n string extends Key<R>\n ? string\n : Key<R> extends infer K\n ? K extends `${P}_${number}_${infer S}`\n ? S\n : never\n : never\n/** A slot field's value type; `unknown` for an untyped record. */\ntype SlotValue<R extends FieldsRecord, K> =\n string extends Key<R> ? unknown : Content<R>[Extract<Key<R>, K>]\n\n/** `{ 'data-zap': 'blog/hola#cover' }` in preview, `{}` otherwise. */\nexport type ZapAttrs = { 'data-zap'?: string }\n\nexport interface FieldImage extends ZapAttrs {\n src: string | undefined\n alt: string | undefined\n width: number | undefined\n height: number | undefined\n}\n\n/**\n * One numbered slot of `f.list(prefix, n)`: the same helpers as `fields`,\n * scoped to the slot. With a suffix they read `prefix_{i}_{suffix}`\n * (`nav.text('texto')` is `nav_1_texto`); without one, `prefix_{i}` itself\n * (`stat.text()` is `stat_1`).\n */\nexport interface FieldSlot<R extends FieldsRecord, P extends string> {\n /** `prefix_{i}`, unique in the list: a React key. */\n readonly key: string\n /** 1 for the first slot. */\n readonly index: number\n /** True when every field of the slot is empty (or the slot has no field). */\n readonly empty: boolean\n /** A text field as delivered, with its stega marker in preview. */\n text(suffix?: SlotSuffix<R, P>): string\n /** The single field `prefix_{i}`, with any marker removed. */\n value(): SlotValue<R, `${P}_${number}`>\n /** The slot's field `prefix_{i}_{suffix}`, with any marker removed. */\n value<S extends SlotSuffix<R, P>>(suffix: S): SlotValue<R, `${P}_${number}_${S}`>\n /** `{ 'data-zap': ref#key }` in preview, `{}` otherwise. */\n attrs(suffix?: SlotSuffix<R, P>): ZapAttrs\n /** An image or media field as `<img>` attributes, tagged in preview. */\n image(suffix?: SlotSuffix<R, P>): FieldImage\n}\n\nexport interface Fields<R extends FieldsRecord> {\n /** True when the helpers tag (the record was read in preview). */\n readonly preview: boolean\n /** A text field as delivered: with its stega marker in preview, so the overlay finds it. */\n text<K extends TextKey<R>>(key: K): string\n /** A field's value with any marker removed: safe in attributes, URLs, comparisons. */\n value<K extends Key<R>>(key: K): Content<R>[K]\n /** `{ 'data-zap': ref#key }` in preview, `{}` otherwise: spread it on the element. */\n attrs(key: Key<R>): ZapAttrs\n /** An image or media field as `<img>` attributes, tagged in preview. */\n image(key: Key<R>): FieldImage\n /**\n * Slots `1` … `count` of a numbered prefix, each a `FieldSlot`: fields\n * `prefix_1` … `prefix_n`, or slots of several fields\n * `prefix_{i}_{suffix}` (`nav_1_texto`, `nav_1_url`), read by suffix.\n */\n list<P extends ListPrefix<R>>(prefix: P, count: number): FieldSlot<R, P>[]\n}\n\nexport interface FieldsOptions {\n /** Force preview on or off; default: on when any text value carries stega. */\n preview?: boolean\n}\n\nexport function fields<R extends FieldsRecord>(record: R, options: FieldsOptions = {}): Fields<R> {\n const content = (record?.content ?? {}) as Record<string, unknown>\n const preview = options.preview ?? Object.values(content).some((value) => hasStega(value))\n const [ref] = recordRefsOf(record)\n const attrs = (key: string): ZapAttrs => (preview && ref ? { 'data-zap': `${ref}#${key}` } : {})\n const value = (key: string) => cleanStega(content[key])\n const text = (key: string) => {\n const raw = content[key]\n return typeof raw === 'string' ? (preview ? raw : cleanStega(raw)) : ''\n }\n const image = (key: string): FieldImage => {\n const media = (value(key) ?? {}) as Record<string, unknown>\n const pick = <T>(name: string, type: string) =>\n typeof media[name] === type ? (media[name] as T) : undefined\n return {\n src: pick<string>('url', 'string'),\n alt: pick<string>('alt', 'string'),\n width: pick<number>('width', 'number'),\n height: pick<number>('height', 'number'),\n ...attrs(key),\n }\n }\n\n return {\n preview,\n text,\n value: value as Fields<R>['value'],\n attrs,\n image,\n list: (prefix, count) =>\n Array.from({ length: Math.max(0, Math.floor(count)) }, (_, i) => {\n const key = `${prefix}_${i + 1}`\n const at = (suffix?: string) => (suffix ? `${key}_${suffix}` : key)\n const own = Object.keys(content).filter((k) => k === key || k.startsWith(`${key}_`))\n return {\n key,\n index: i + 1,\n empty: own.every((k) => {\n const v = value(k)\n return v === null || v === undefined || (typeof v === 'string' && !v.trim())\n }),\n text: (suffix?: string) => text(at(suffix)),\n value: (suffix?: string) => value(at(suffix)),\n attrs: (suffix?: string) => attrs(at(suffix)),\n image: (suffix?: string) => image(at(suffix)),\n } as FieldSlot<R, typeof prefix>\n }),\n }\n}\n"],"mappings":"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","names":["value"]}