@openpresentation/opf-editor 0.24.2 → 0.25.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,142 @@
1
+ // RR-98: read a deck file of any OPF source format. `readDeckFile(file | { name, text })` detects JSON, YAML or Markdown (the extension decides
2
+ // first, then the content), converts YAML and Markdown with core's browser-safe `fromYaml` and `fromMarkdown` (`@openpresentation/opf/yaml`, `/markdown`,
3
+ // loaded on first use) and returns `{ document, format, name, findings }`. Plain functions, no DOM, no network. A host's Open button passes
4
+ // `DECK_FILE_ACCEPT` to `<input accept>` and the chosen file to `readDeckFile`.
5
+ import { MAX_OPF_BYTES, unwrapOpf } from "./transfer.js";
6
+
7
+ /** The `accept` value for a file input that opens a deck: JSON (`.json`, `.opf`), YAML and Markdown. */
8
+ export const DECK_FILE_ACCEPT = ".json,.opf,.yaml,.yml,.md";
9
+
10
+ /** Raised by `readDeckFile`. `code` says why; `format` is the format that was tried first; `findings` holds the parser's findings, if any. */
11
+ export class DeckFileError extends Error {
12
+ constructor(code, message, { format, name, findings = [], cause } = {}) {
13
+ super(message, cause ? { cause } : undefined);
14
+ this.name = "DeckFileError";
15
+ this.code = code;
16
+ this.format = format;
17
+ this.fileName = name;
18
+ this.findings = findings;
19
+ }
20
+ }
21
+
22
+ const YAML_EXTENSIONS = /\.(?:ya?ml)$/i;
23
+ const MARKDOWN_EXTENSIONS = /\.(?:md|markdown)$/i;
24
+ const COMPOUND = /\.opf\.(?:json|ya?ml|md|markdown)$/i;
25
+ const PLAIN = /\.(?:json|opf|ya?ml|md|markdown)$/i;
26
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
27
+ const stripBom = (text) => text.replace(/^/, "");
28
+
29
+ /** The file name without its directory and its `.opf.json`, `.opf.yaml`, `.opf.md` or plain extension. */
30
+ export function deckFileName(fileName) {
31
+ const base = String(fileName ?? "").split(/[\\/]/).pop().trim();
32
+ const stripped = base.replace(COMPOUND, "").replace(PLAIN, "");
33
+ const name = stripped !== base ? stripped : base.replace(/(?<=.)\.[A-Za-z0-9]{1,8}$/, "");
34
+ return name || "deck";
35
+ }
36
+
37
+ /** The format the extension suggests: `.yaml` and `.yml` are YAML, `.md` Markdown, and `.json`, `.opf` or anything else JSON. */
38
+ export function deckFileFormat(fileName) {
39
+ const name = String(fileName ?? "");
40
+ return YAML_EXTENSIONS.test(name) ? "yaml" : MARKDOWN_EXTENSIONS.test(name) ? "md" : "json";
41
+ }
42
+
43
+ const hasFrontMatter = (text) => /^---[ \t]*\r?\n/.test(stripBom(text).replace(/^\s*(?=---)/, ""));
44
+ const looksBinary = (text) => text.startsWith("PK\u0003\u0004") || text.includes("\u0000");
45
+
46
+ function failure(code, message, context, extra) {
47
+ return new DeckFileError(code, message, { ...context, ...extra });
48
+ }
49
+
50
+ async function readSource(input) {
51
+ if (input === null || typeof input !== "object") throw failure("unreadable", "Choose a deck file to open.", {});
52
+ const name = typeof input.name === "string" ? input.name : "";
53
+ const context = { name };
54
+ if (typeof input.size === "number" && input.size > MAX_OPF_BYTES) throw failure("file-too-large", "Choose a file smaller than 20 MB.", context);
55
+ let text = input.text;
56
+ try {
57
+ if (typeof text === "function") text = await input.text();
58
+ } catch (cause) {
59
+ throw failure("unreadable", `${name || "The file"} could not be read.${cause?.message ? ` ${cause.message}` : ""}`, context, { cause });
60
+ }
61
+ if (typeof text !== "string") throw failure("unreadable", "Pass a File, or { name, text } with the file's text.", context);
62
+ if (new TextEncoder().encode(text).length > MAX_OPF_BYTES) throw failure("file-too-large", "Choose a file smaller than 20 MB.", context);
63
+ return { name, text: stripBom(text), context };
64
+ }
65
+
66
+ function readJson(text, context) {
67
+ let value;
68
+ try {
69
+ value = JSON.parse(text);
70
+ } catch (cause) {
71
+ throw failure("invalid-json", `This is not valid JSON: ${cause.message}`, context, { format: "json", cause });
72
+ }
73
+ const document = unwrapOpf(value);
74
+ if (!isObject(document) || !Array.isArray(document.slides)) {
75
+ throw failure("not-a-deck", "This JSON is not an OPF deck: a deck has a \"slides\" list.", context, { format: "json" });
76
+ }
77
+ return { document, findings: [] };
78
+ }
79
+
80
+ // A conversion result as a deck, or a typed error when the text itself could not be read (a syntax or dialect error, not an OPF finding).
81
+ function fromConversion(result, format, context) {
82
+ const findings = result.findings ?? [];
83
+ const syntax = findings.filter((finding) => finding.severity === "error" && !String(finding.ruleId ?? "").startsWith("opf/"));
84
+ if (syntax.length) {
85
+ const first = syntax[0];
86
+ const place = first.location?.line ? ` (line ${first.location.line})` : "";
87
+ throw failure(format === "yaml" ? "invalid-yaml" : "invalid-markdown", `This is not valid ${format === "yaml" ? "YAML" : "Markdown"}${place}: ${first.message}`, context, { format, findings });
88
+ }
89
+ return { document: result.presentation, findings };
90
+ }
91
+
92
+ async function convert(format, text, options, context) {
93
+ if (format === "yaml") {
94
+ const { fromYaml } = await import("@openpresentation/opf/yaml");
95
+ return fromConversion(fromYaml(text, { catalogs: options.catalogs }), "yaml", context);
96
+ }
97
+ const { fromMarkdown } = await import("@openpresentation/opf/markdown");
98
+ return fromConversion(fromMarkdown(text, { catalogs: options.catalogs }), "md", context);
99
+ }
100
+
101
+ /**
102
+ * Read a deck file. `file` is a `File` (or anything with `name` and `text()`), or `{ name, text }` with the text as a string.
103
+ *
104
+ * The extension picks the format: `.yaml` and `.yml` are YAML, `.md` is Markdown, and `.json`, `.opf` or anything else is JSON. Text that is not
105
+ * valid JSON is then tried as YAML, and as Markdown when it starts with front matter (`---`). The result is
106
+ * `{ document, format: "json" | "yaml" | "md", name, findings }`: `format` is the one that read it, `name` is the file name without
107
+ * `.opf.json`, `.opf.yaml`, `.opf.md` or its plain extension, and `findings` are the conversion findings of YAML and Markdown (core's
108
+ * `Finding`, each with a JSON Pointer `path` and a `location` in the text; JSON gives none). A deck that reads but has OPF errors is returned,
109
+ * with those findings; text that cannot be read at all throws `DeckFileError`.
110
+ *
111
+ * `options.catalogs`: the host's catalogs, for the references check of the YAML and Markdown conversion.
112
+ */
113
+ export async function readDeckFile(file, options = {}) {
114
+ const { name: fileName, text, context } = await readSource(file);
115
+ const name = deckFileName(fileName);
116
+ const chosen = deckFileFormat(fileName);
117
+ const done = (format, read) => ({ document: read.document, format, name, findings: read.findings });
118
+ if (!text.trim()) throw failure("empty-file", `${fileName || "The file"} is empty.`, context, { format: chosen });
119
+ if (looksBinary(text)) throw failure("unsupported-file", "This looks like a PowerPoint or other binary file, not an OPF deck. Open a .json, .yaml or .md deck.", context, { format: chosen });
120
+
121
+ if (chosen === "yaml") return done("yaml", await convert("yaml", text, options, context));
122
+ if (chosen === "md") return done("md", await convert("md", text, options, context));
123
+
124
+ let jsonError;
125
+ try {
126
+ return done("json", readJson(text, context));
127
+ } catch (error) {
128
+ // A parsed value that is not a deck is a real answer. Only text that is not JSON falls through to the other formats.
129
+ if (error.code !== "invalid-json") throw error;
130
+ jsonError = error;
131
+ }
132
+ try {
133
+ const read = await convert("yaml", text, options, context);
134
+ if (isObject(read.document) && Array.isArray(read.document.slides)) return done("yaml", read);
135
+ } catch (error) {
136
+ if (!(error instanceof DeckFileError)) throw error;
137
+ }
138
+ if (hasFrontMatter(text)) {
139
+ try { return done("md", await convert("md", text, options, context)); } catch (error) { if (!(error instanceof DeckFileError)) throw error; }
140
+ }
141
+ throw failure("invalid-json", `${jsonError.message} It is not valid YAML${hasFrontMatter(text) ? " or Markdown" : ""} either.`, context, { format: "json", cause: jsonError.cause });
142
+ }
@@ -9,6 +9,7 @@ import {
9
9
  import { listSwitchOptions } from "./switches.js";
10
10
  import { layoutRegionSummary } from "./layout-templates.js";
11
11
  import { groupLayoutChoices } from "./layout-templates.js";
12
+ import { buildLayoutDiagram } from "./layout-diagram.js";
12
13
  import { switchDimension } from "./switches.js";
13
14
 
14
15
  let instances = 0;
@@ -521,13 +522,8 @@ export function createSlideManager(container, options) {
521
522
  template.replaceChildren(); currentPreview.replaceChildren();
522
523
  if (choice?.record?.preview && typeof choice.record.preview === "string") {
523
524
  template.append(el("img", { src: choice.record.preview, alt: `${choice.label} template preview` }));
524
- } else if (Array.isArray(choice?.record?.areas)) {
525
- const diagram = el("div", { class: "opf-layout-diagram" });
526
- diagram.style.gridTemplateAreas = choice.record.areas.map((row) => `"${row.join(" ")}"`).join(" ");
527
- diagram.style.gridTemplateColumns = `repeat(${choice.record.areas[0]?.length ?? 1}, 1fr)`;
528
- const areas = [...new Set(choice.record.areas.flat())].filter((area) => area !== ".");
529
- for (const area of areas) { const part = el("span", { text: area }); part.style.gridArea = area; diagram.append(part); }
530
- template.append(diagram);
525
+ } else if (Array.isArray(choice?.record?.regions)) {
526
+ template.append(buildLayoutDiagram(document, choice.record));
531
527
  }
532
528
  if (choice && options.renderLayoutPreview) {
533
529
  try { currentPreview.innerHTML = options.renderLayoutPreview(deck(), current(), choice) ?? ""; }