src/deck.js
Rama main · réplica de lectura del repositorio original.
import { WeaveError } from './engine.js';
import { create } from './vendor/decklet/create.mjs';
import { validate } from './vendor/decklet/validate.mjs';
export const DECK = Object.freeze({
slides: 'Slides',
model: 'Model',
chrome: 'Chrome',
style: 'Style',
order: 'Order',
space: 'Space',
format: 'Format',
layout: 'Layout',
version: 'Version',
key: 'Key',
supersedes: 'Supersedes',
});
const fieldsOf = (db) => Object.values(db.fields ?? {});
const named = (db, name) => fieldsOf(db).find((f) => f.name === name);
const isSlideTable = (db) => named(db, DECK.model)?.type === 'document';
export function deckRole(db, tables = null) {
if (!db) return null;
const slides = named(db, DECK.slides);
if (slides?.type === 'relation' && slides.config?.many
&& (!tables || isSlideTable(tables[slides.config.targetDb]))) return 'deck';
if (isSlideTable(db)) return 'slide';
return null;
}
export function parseJsonDoc(text) {
const raw = String(text ?? '').trim();
if (!raw) return null;
const fenced = raw.match(/```(?:json|js|javascript)?\s*\n([\s\S]*?)\n?```/);
return JSON.parse(fenced ? fenced[1] : raw);
}
const errorSlide = (label, message) => ({
name: label,
els: [
{ role: 'H1', x: 60, y: 190, w: 840, text: `${label} could not be read` },
{ role: 'Body', x: 60, y: 250, w: 840, color: 'var(--muted)', text: String(message).slice(0, 240) },
],
});
const placeholderSlide = (title) => ({
name: title,
els: [
{ role: 'H1', x: 60, y: 190, w: 840, text: title },
{ role: 'Body', x: 60, y: 250, w: 840, color: 'var(--muted)', text: 'Link slides to this deck and they compose here, in link order.' },
],
});
function slideFrom(weave, summary, warnings) {
const ent = weave.readEntity(summary.id);
const label = `${ent.db.split('/').pop()}#${ent.publicId}`;
let parsed;
try {
parsed = parseJsonDoc(ent.docs?.[DECK.model]);
} catch (err) {
warnings.push(`${label} — ${DECK.model} is not JSON: ${err.message}`);
return errorSlide(label, err.message);
}
if (parsed == null) {
warnings.push(`${label} — ${DECK.model} is empty`);
return errorSlide(label, `${DECK.model} is empty`);
}
const slide = Array.isArray(parsed) ? { els: parsed } : { ...parsed };
if (!Array.isArray(slide.els)) {
warnings.push(`${label} — no els array in ${DECK.model}`);
return errorSlide(label, 'the model has no els array');
}
slide.name = slide.name ?? ent.name ?? label;
const layout = ent.fields?.[DECK.layout];
if (layout) slide.layout = typeof layout === 'object' ? layout.name : String(layout);
return slide;
}
function ordered(summaries, order) {
const list = String(order ?? '').split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
if (!list.length) return summaries;
const picked = [];
for (const ref of list) {
const bare = ref.replace(/^.*#/, '');
const hit = summaries.find((s) => s.id === ref || String(s.publicId) === bare
|| s.name?.toLowerCase() === ref.toLowerCase());
if (hit && !picked.includes(hit)) picked.push(hit);
}
return picked.length ? picked : summaries;
}
const docJson = (ent, field, warnings) => {
try {
return parseJsonDoc(ent.docs?.[field]) ?? null;
} catch (err) {
warnings.push(`${field} is not JSON: ${err.message}`);
return null;
}
};
const plainValue = (v) => (v && typeof v === 'object' ? (v.name ?? null) : v);
export function composeDeckModel(weave, ref, { admit = () => true } = {}) {
const ent = weave.readEntity(ref);
const db = weave.state.tables[ent.dbId];
if (deckRole(db, weave.state.tables) !== 'deck') {
throw new WeaveError(`'${ent.db}#${ent.publicId}' is not a deck — a deck table has a many-relation named '${DECK.slides}'`, 'invalid');
}
const warnings = [];
const chrome = docJson(ent, DECK.chrome, warnings) ?? {};
const style = docJson(ent, DECK.style, warnings);
const linked = (ent.fields?.[DECK.slides] ?? []).filter((s) => admit(s.id));
const picked = ordered(linked, ent.fields?.[DECK.order]);
const slides = picked.map((s) => slideFrom(weave, s, warnings));
if (!slides.length) {
warnings.push('this deck has no slides yet');
slides.push(placeholderSlide(ent.name || 'Empty deck'));
}
const model = { ...chrome, title: ent.name || 'deck', slides };
return {
model, style, warnings, entity: ent,
options: {
title: ent.name || undefined,
format: plainValue(ent.fields?.[DECK.format]) || undefined,
space: plainValue(ent.fields?.[DECK.space]) || undefined,
},
};
}
export function composeSlideModel(weave, ref, { admit = () => true } = {}) {
const ent = weave.readEntity(ref);
const db = weave.state.tables[ent.dbId];
if (deckRole(db, weave.state.tables) !== 'slide') {
throw new WeaveError(`'${ent.db}#${ent.publicId}' is not a slide — a slide table has a document field named '${DECK.model}'`, 'invalid');
}
const warnings = [];
const parent = firstDeckOf(weave, ent, admit);
const context = parent ? composeDeckModel(weave, parent.id, { admit }) : null;
const slide = slideFrom(weave, { id: ent.id }, warnings);
const model = context
? { ...context.model, title: ent.name || context.model.title, slides: [slide] }
: { title: ent.name || 'slide', slides: [slide] };
return {
model, style: context?.style ?? null, warnings, entity: ent,
options: { ...(context?.options ?? {}), title: ent.name || undefined },
};
}
function firstDeckOf(weave, ent, admit) {
const db = weave.state.tables[ent.dbId];
for (const f of fieldsOf(db)) {
if (f.type !== 'relation') continue;
if (deckRole(weave.state.tables[f.config?.targetDb], weave.state.tables) !== 'deck') continue;
const val = ent.fields?.[f.name];
const first = (Array.isArray(val) ? val : [val]).find((d) => d && admit(d.id));
if (first) return first;
}
return null;
}
export function renderDeck(weave, ref, { template, admit = () => true } = {}) {
const ent = weave.readEntity(ref);
const role = deckRole(weave.state.tables[ent.dbId], weave.state.tables);
const composed = role === 'slide' ? composeSlideModel(weave, ref, { admit }) : composeDeckModel(weave, ref, { admit });
const { html, deck } = create(composed.model, { ...composed.options, style: composed.style, template });
const v = validate(deck);
return { html, model: deck, errors: v.errors, warnings: [...composed.warnings, ...v.warnings] };
}
export function newSlideVersion(weave, ref, { promote = false } = {}) {
const ent = weave.readEntity(ref);
const db = weave.state.tables[ent.dbId];
if (deckRole(db, weave.state.tables) !== 'slide') {
throw new WeaveError(`'${ent.db}#${ent.publicId}' is not a slide — versioning is for slide tables (a document field named '${DECK.model}')`, 'invalid');
}
if (!named(db, DECK.version)) {
throw new WeaveError(`'${ent.db}' has no '${DECK.version}' field — a slide table versions on ${DECK.version} + ${DECK.key}`, 'invalid');
}
const values = {};
for (const f of fieldsOf(db)) {
if (f.system || ['lookup', 'rollup', 'formula', 'document', 'attachments', 'avatar'].includes(f.type)) continue;
if (f.name === DECK.version || f.name === DECK.supersedes) continue;
if (f.type === 'relation' && f.config?.many) continue;
const raw = ent.raw?.[f.name];
const val = f.type === 'relation' ? (Array.isArray(raw) ? raw[0] : raw) : raw;
if (val != null && val !== '') values[f.name] = val;
}
const current = Number(ent.fields?.[DECK.version] ?? 1);
values[DECK.version] = (Number.isFinite(current) ? current : 1) + 1;
if (named(db, DECK.supersedes)?.type === 'relation') values[DECK.supersedes] = ent.id;
const docs = {};
for (const [name, text] of Object.entries(ent.docs ?? {})) if (text) docs[name] = text;
const next = weave.createEntity(db.id, { values, docs });
if (promote) {
for (const f of fieldsOf(db)) {
if (f.type !== 'relation' || !f.config?.many) continue;
if (deckRole(weave.state.tables[f.config?.targetDb], weave.state.tables) !== 'deck') continue;
for (const deckRow of ent.fields?.[f.name] ?? []) {
const holder = weave.readEntity(deckRow.id);
const list = (holder.fields?.[DECK.slides] ?? []).map((s) => (s.id === ent.id ? next.id : s.id));
weave.updateEntity(deckRow.id, { [DECK.slides]: list });
}
}
}
return next;
}