scrolly 0.1.0__py3-none-any.whl
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.
- scrolly/__init__.py +3 -0
- scrolly/_cli/__init__.py +0 -0
- scrolly/_cli/_cli.py +120 -0
- scrolly/deck/__init__.py +43 -0
- scrolly/deck/inference.py +61 -0
- scrolly/deck/model.py +103 -0
- scrolly/deck/parser.py +168 -0
- scrolly/deck/schema.py +104 -0
- scrolly/deck/validator.py +94 -0
- scrolly/errors.py +34 -0
- scrolly/pipeline/__init__.py +6 -0
- scrolly/pipeline/assets.py +119 -0
- scrolly/pipeline/orchestrator.py +90 -0
- scrolly/pipeline/writer.py +38 -0
- scrolly/render/__init__.py +6 -0
- scrolly/render/assembler.py +69 -0
- scrolly/render/assets/canvas.css +770 -0
- scrolly/render/assets/canvas.js +1324 -0
- scrolly/render/bundled_assets.py +55 -0
- scrolly/render/fan.py +110 -0
- scrolly/render/nav_data.py +138 -0
- scrolly/render/templates/index.html.j2 +86 -0
- scrolly/slide/__init__.py +50 -0
- scrolly/slide/compilers/__init__.py +0 -0
- scrolly/slide/compilers/storyboard.py +102 -0
- scrolly/slide/html.py +47 -0
- scrolly/slide/ir/__init__.py +28 -0
- scrolly/slide/ir/_framework/__init__.py +0 -0
- scrolly/slide/ir/_framework/animation.py +43 -0
- scrolly/slide/ir/_framework/base.py +35 -0
- scrolly/slide/ir/_framework/element.py +80 -0
- scrolly/slide/ir/_framework/utils.py +98 -0
- scrolly/slide/ir/scrollimation.py +93 -0
- scrolly/slide/ir/static.py +176 -0
- scrolly/slide/ir/storyboard.py +86 -0
- scrolly/slide/processor.py +35 -0
- scrolly/slide/registry.py +100 -0
- scrolly/slide/renderers/__init__.py +0 -0
- scrolly/slide/renderers/scrollimation.py +295 -0
- scrolly/slide/renderers/static.py +80 -0
- scrolly-0.1.0.dist-info/METADATA +65 -0
- scrolly-0.1.0.dist-info/RECORD +45 -0
- scrolly-0.1.0.dist-info/WHEEL +4 -0
- scrolly-0.1.0.dist-info/entry_points.txt +2 -0
- scrolly-0.1.0.dist-info/licenses/LICENSE +21 -0
scrolly/__init__.py
ADDED
scrolly/_cli/__init__.py
ADDED
|
File without changes
|
scrolly/_cli/_cli.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from scrolly import __version__
|
|
9
|
+
from scrolly.errors import ScrollyError
|
|
10
|
+
from scrolly.pipeline import build_deck, validate_deck_sources
|
|
11
|
+
|
|
12
|
+
_err_console = Console(stderr=True, highlight=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.version_option(__version__, prog_name="scrolly")
|
|
17
|
+
def cli() -> None:
|
|
18
|
+
"""scrolly — compile a JSON5 deck into a self-contained 2D-canvas HTML presentation."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cli.command()
|
|
22
|
+
@click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
23
|
+
@click.option(
|
|
24
|
+
"--out",
|
|
25
|
+
"out_dir",
|
|
26
|
+
required=True,
|
|
27
|
+
type=click.Path(file_okay=False, path_type=Path),
|
|
28
|
+
help="Output directory.",
|
|
29
|
+
)
|
|
30
|
+
@click.option("--force", is_flag=True, help="Overwrite a non-empty output directory.")
|
|
31
|
+
@click.option("--no-inline", is_flag=True, help="Write assets as separate files instead of inlining.")
|
|
32
|
+
def build(deck_path: Path, out_dir: Path, force: bool, no_inline: bool) -> None:
|
|
33
|
+
"""Build a deck into a self-contained HTML presentation."""
|
|
34
|
+
try:
|
|
35
|
+
deck = build_deck(deck_path, out_dir, force=force, inline=not no_inline)
|
|
36
|
+
except ScrollyError as e:
|
|
37
|
+
_err_console.print(f"[red]error:[/red] {e}")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
click.echo(f"Built '{deck.title or '(untitled)'}': {len(deck.slides)} slides, {len(deck.edges)} edges → {out_dir}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@cli.command()
|
|
44
|
+
@click.argument("type_name", required=False)
|
|
45
|
+
def schema(type_name: str | None) -> None:
|
|
46
|
+
"""Show source file schemas. Lists types when called without an argument."""
|
|
47
|
+
from scrolly.deck import deck_source_schema
|
|
48
|
+
from scrolly.slide import registered_ir_types
|
|
49
|
+
|
|
50
|
+
ir_types = registered_ir_types()
|
|
51
|
+
|
|
52
|
+
if type_name is None:
|
|
53
|
+
click.echo("Available schemas:\n")
|
|
54
|
+
click.echo(f" {'deck':<17}{'.deck.json':<24}Deck structure (slides + edges)")
|
|
55
|
+
for name in sorted(ir_types):
|
|
56
|
+
cls = ir_types[name]
|
|
57
|
+
click.echo(f" {name:<17}{cls.SUFFIX:<24}{cls.DESCRIPTION}")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
if type_name == "deck":
|
|
61
|
+
click.echo(json.dumps(deck_source_schema(), indent=2))
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if type_name not in ir_types:
|
|
65
|
+
known = ", ".join(sorted(["deck", *ir_types]))
|
|
66
|
+
_err_console.print(f"[red]error:[/red] unknown type '{type_name}' (known: {known})")
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
click.echo(json.dumps(ir_types[type_name].source_schema(), indent=2))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@cli.command()
|
|
73
|
+
@click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
74
|
+
def validate(deck_path: Path) -> None:
|
|
75
|
+
"""Validate a deck and all its slide sources without building."""
|
|
76
|
+
try:
|
|
77
|
+
deck = validate_deck_sources(deck_path)
|
|
78
|
+
except ScrollyError as e:
|
|
79
|
+
_err_console.print(f"[red]error:[/red] {e}")
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
click.echo(f"Valid: {len(deck.slides)} slides, {len(deck.edges)} edges")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_INIT_DECK = """\
|
|
86
|
+
{
|
|
87
|
+
title: "My Deck",
|
|
88
|
+
slides: [
|
|
89
|
+
{ id: "intro", position: [0, 0], source: "slides/intro.static.md" },
|
|
90
|
+
],
|
|
91
|
+
edges: [],
|
|
92
|
+
}
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_INIT_SLIDE = """\
|
|
96
|
+
---
|
|
97
|
+
initial_scroll_position: 0
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
# My Deck
|
|
101
|
+
|
|
102
|
+
Welcome to your new presentation.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@cli.command()
|
|
107
|
+
@click.argument("dir_path", type=click.Path(path_type=Path))
|
|
108
|
+
def init(dir_path: Path) -> None:
|
|
109
|
+
"""Scaffold a minimal deck in DIR_PATH."""
|
|
110
|
+
if dir_path.exists() and any(dir_path.iterdir()):
|
|
111
|
+
_err_console.print(f"[red]error:[/red] directory is not empty: {dir_path}")
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
slides_dir = dir_path / "slides"
|
|
115
|
+
slides_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
(dir_path / "deck.deck.json").write_text(_INIT_DECK)
|
|
118
|
+
(slides_dir / "intro.static.md").write_text(_INIT_SLIDE)
|
|
119
|
+
|
|
120
|
+
click.echo(f"Created deck in {dir_path}")
|
scrolly/deck/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Deck model, parsing, validation, and inference (Layer A).
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
raw_deck = parse_deck(path)
|
|
5
|
+
validate_raw_deck(raw_deck)
|
|
6
|
+
deck = infer_edges(raw_deck)
|
|
7
|
+
validate_deck(deck)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from scrolly.deck.inference import infer_edges
|
|
11
|
+
from scrolly.deck.model import (
|
|
12
|
+
Deck,
|
|
13
|
+
Edge,
|
|
14
|
+
Endpoint,
|
|
15
|
+
Position,
|
|
16
|
+
RawDeck,
|
|
17
|
+
RawEdge,
|
|
18
|
+
RawEndpoint,
|
|
19
|
+
Side,
|
|
20
|
+
Slide,
|
|
21
|
+
SlideGroup,
|
|
22
|
+
)
|
|
23
|
+
from scrolly.deck.parser import parse_deck
|
|
24
|
+
from scrolly.deck.schema import deck_source_schema
|
|
25
|
+
from scrolly.deck.validator import validate_deck, validate_raw_deck
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Deck",
|
|
29
|
+
"Edge",
|
|
30
|
+
"Endpoint",
|
|
31
|
+
"SlideGroup",
|
|
32
|
+
"Position",
|
|
33
|
+
"RawDeck",
|
|
34
|
+
"RawEdge",
|
|
35
|
+
"RawEndpoint",
|
|
36
|
+
"Side",
|
|
37
|
+
"Slide",
|
|
38
|
+
"deck_source_schema",
|
|
39
|
+
"infer_edges",
|
|
40
|
+
"parse_deck",
|
|
41
|
+
"validate_deck",
|
|
42
|
+
"validate_raw_deck",
|
|
43
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Fill in omitted edge sides by inferring from slide positions.
|
|
2
|
+
|
|
3
|
+
An endpoint with `side=None` has its side inferred from where the other
|
|
4
|
+
endpoint's slide sits relative to this one on the grid:
|
|
5
|
+
|
|
6
|
+
dx > 0 → right dx < 0 → left
|
|
7
|
+
dy > 0 → bottom dy < 0 → top
|
|
8
|
+
|
|
9
|
+
If the two slides are diagonal (dx ≠ 0 and dy ≠ 0), there is no unique
|
|
10
|
+
side and a `DeckInferenceError` is raised.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from scrolly.deck.model import Deck, Edge, Endpoint, Position, RawDeck, Side
|
|
16
|
+
from scrolly.errors import DeckInferenceError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def infer_edges(deck: RawDeck) -> Deck:
|
|
20
|
+
"""Return a `Deck` with every edge's sides fully specified."""
|
|
21
|
+
positions = {slide.id: slide.position for slide in deck.slides}
|
|
22
|
+
inferred: list[Edge] = []
|
|
23
|
+
|
|
24
|
+
for idx, edge in enumerate(deck.edges):
|
|
25
|
+
a_pos = positions[edge.a.slide_id]
|
|
26
|
+
b_pos = positions[edge.b.slide_id]
|
|
27
|
+
|
|
28
|
+
a_side = edge.a.side or _infer_side(a_pos, b_pos, idx, edge.a.slide_id, edge.b.slide_id)
|
|
29
|
+
b_side = edge.b.side or _infer_side(b_pos, a_pos, idx, edge.b.slide_id, edge.a.slide_id)
|
|
30
|
+
|
|
31
|
+
inferred.append(
|
|
32
|
+
Edge(
|
|
33
|
+
a=Endpoint(slide_id=edge.a.slide_id, side=a_side),
|
|
34
|
+
b=Endpoint(slide_id=edge.b.slide_id, side=b_side),
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return Deck(title=deck.title, slides=deck.slides, edges=tuple(inferred), groups=deck.groups)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _infer_side(
|
|
42
|
+
from_pos: Position,
|
|
43
|
+
to_pos: Position,
|
|
44
|
+
edge_idx: int,
|
|
45
|
+
from_id: str,
|
|
46
|
+
to_id: str,
|
|
47
|
+
) -> Side:
|
|
48
|
+
"""Return the side of `from_pos`'s slide that faces `to_pos`'s slide."""
|
|
49
|
+
dx = to_pos.x - from_pos.x
|
|
50
|
+
dy = to_pos.y - from_pos.y
|
|
51
|
+
|
|
52
|
+
if dx != 0 and dy == 0:
|
|
53
|
+
return Side.RIGHT if dx > 0 else Side.LEFT
|
|
54
|
+
if dy != 0 and dx == 0:
|
|
55
|
+
return Side.BOTTOM if dy > 0 else Side.TOP
|
|
56
|
+
|
|
57
|
+
# Diagonal or same cell — no unique side.
|
|
58
|
+
raise DeckInferenceError(
|
|
59
|
+
f"edges[{edge_idx}]: cannot infer side for '{from_id}' — "
|
|
60
|
+
f"'{from_id}' and '{to_id}' are not on the same row or column"
|
|
61
|
+
)
|
scrolly/deck/model.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Data model for the deck (Layer A).
|
|
2
|
+
|
|
3
|
+
The parser produces a `RawDeck` (edges may have omitted sides). The inference
|
|
4
|
+
step fills in omitted sides and produces a `Deck` (every edge side is known).
|
|
5
|
+
Downstream layers work exclusively with the fully-specified `Deck`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Side(Enum):
|
|
16
|
+
"""One of the four sides of a slide. Edges attach to sides."""
|
|
17
|
+
|
|
18
|
+
TOP = "top"
|
|
19
|
+
BOTTOM = "bottom"
|
|
20
|
+
LEFT = "left"
|
|
21
|
+
RIGHT = "right"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Position:
|
|
26
|
+
"""Integer grid position. x increases left → right, y increases top → bottom."""
|
|
27
|
+
|
|
28
|
+
x: int
|
|
29
|
+
y: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Slide:
|
|
34
|
+
"""One slide on the canvas.
|
|
35
|
+
|
|
36
|
+
The slide's *type* (which renderer handles it) is encoded in the
|
|
37
|
+
`source` filename's suffix and resolved at render time via
|
|
38
|
+
`scrolly.slide.get_renderer_for_path`. No `type` field on the model.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
position: Position
|
|
43
|
+
source: Path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class RawEndpoint:
|
|
48
|
+
"""Edge endpoint pre-inference — the side may be omitted."""
|
|
49
|
+
|
|
50
|
+
slide_id: str
|
|
51
|
+
side: Side | None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Endpoint:
|
|
56
|
+
"""Edge endpoint post-inference — the side is always known."""
|
|
57
|
+
|
|
58
|
+
slide_id: str
|
|
59
|
+
side: Side
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class RawEdge:
|
|
64
|
+
"""Undirected edge between two slides, pre-inference."""
|
|
65
|
+
|
|
66
|
+
a: RawEndpoint
|
|
67
|
+
b: RawEndpoint
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Edge:
|
|
72
|
+
"""Undirected edge between two slides, fully specified."""
|
|
73
|
+
|
|
74
|
+
a: Endpoint
|
|
75
|
+
b: Endpoint
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class SlideGroup:
|
|
80
|
+
"""A named group of slides, visualised as a background rectangle in deck view."""
|
|
81
|
+
|
|
82
|
+
label: str
|
|
83
|
+
slide_ids: tuple[str, ...]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class RawDeck:
|
|
88
|
+
"""Deck as parsed from disk, with edges potentially missing side info."""
|
|
89
|
+
|
|
90
|
+
title: str | None
|
|
91
|
+
slides: tuple[Slide, ...]
|
|
92
|
+
edges: tuple[RawEdge, ...]
|
|
93
|
+
groups: tuple[SlideGroup, ...] = ()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Deck:
|
|
98
|
+
"""Deck after inference, with every edge side fully specified."""
|
|
99
|
+
|
|
100
|
+
title: str | None
|
|
101
|
+
slides: tuple[Slide, ...]
|
|
102
|
+
edges: tuple[Edge, ...]
|
|
103
|
+
groups: tuple[SlideGroup, ...] = ()
|
scrolly/deck/parser.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Parse a JSON5 deck file into a `RawDeck`.
|
|
2
|
+
|
|
3
|
+
Purely syntactic: checks file shape (required fields, types) and returns a
|
|
4
|
+
`RawDeck` whose edges may still have omitted sides. Semantic checks (unique
|
|
5
|
+
ids, edges reference real slides, one-slide-per-cell, diagonal-inference
|
|
6
|
+
rejection) live in `validator.py` and `inference.py`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import json5
|
|
15
|
+
|
|
16
|
+
from scrolly.deck.model import Position, RawDeck, RawEdge, RawEndpoint, Side, Slide, SlideGroup
|
|
17
|
+
from scrolly.errors import DeckParseError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_deck(deck_path: Path) -> RawDeck:
|
|
21
|
+
"""Load and parse a JSON5 deck file.
|
|
22
|
+
|
|
23
|
+
Slide source paths are resolved relative to the deck file's directory.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
raw = json5.loads(deck_path.read_text())
|
|
27
|
+
except ValueError as e:
|
|
28
|
+
raise DeckParseError(f"{deck_path}: could not parse JSON5: {e}") from e
|
|
29
|
+
|
|
30
|
+
if not isinstance(raw, dict):
|
|
31
|
+
raise DeckParseError(f"{deck_path}: deck file must be a JSON5 object, got {type(raw).__name__}")
|
|
32
|
+
|
|
33
|
+
title = raw.get("title")
|
|
34
|
+
if title is not None and not isinstance(title, str):
|
|
35
|
+
raise DeckParseError(f"{deck_path}: field 'title' must be a string if present")
|
|
36
|
+
|
|
37
|
+
slides_raw = _require_list(raw, "slides", deck_path)
|
|
38
|
+
slides, groups = _parse_slides_and_groups(slides_raw, deck_path)
|
|
39
|
+
|
|
40
|
+
edges_raw = raw.get("edges", [])
|
|
41
|
+
if not isinstance(edges_raw, list):
|
|
42
|
+
raise DeckParseError(f"{deck_path}: field 'edges' must be a list if present")
|
|
43
|
+
edges = tuple(_parse_edge(e, idx) for idx, e in enumerate(edges_raw))
|
|
44
|
+
|
|
45
|
+
return RawDeck(title=title, slides=slides, edges=edges, groups=groups)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_slides_and_groups(slides_raw: list, deck_path: Path) -> tuple[tuple[Slide, ...], tuple[SlideGroup, ...]]:
|
|
49
|
+
deck_dir = deck_path.parent
|
|
50
|
+
slides: list[Slide] = []
|
|
51
|
+
groups: list[SlideGroup] = []
|
|
52
|
+
flat_idx = 0
|
|
53
|
+
|
|
54
|
+
for top_idx, item in enumerate(slides_raw):
|
|
55
|
+
ctx = f"slides[{top_idx}]"
|
|
56
|
+
if not isinstance(item, dict):
|
|
57
|
+
raise DeckParseError(f"{ctx}: must be an object, got {type(item).__name__}")
|
|
58
|
+
|
|
59
|
+
if "group" in item:
|
|
60
|
+
label = item["group"]
|
|
61
|
+
if not isinstance(label, str) or not label.strip():
|
|
62
|
+
raise DeckParseError(f"{ctx}: 'group' must be a non-empty string")
|
|
63
|
+
label = label.strip()
|
|
64
|
+
|
|
65
|
+
if "slides" not in item or not isinstance(item["slides"], list):
|
|
66
|
+
raise DeckParseError(f"{ctx}: group must have a 'slides' list")
|
|
67
|
+
|
|
68
|
+
group_slide_ids: list[str] = []
|
|
69
|
+
for inner_idx, inner in enumerate(item["slides"]):
|
|
70
|
+
inner_ctx = f"slides[{top_idx}].slides[{inner_idx}]"
|
|
71
|
+
if isinstance(inner, dict) and "group" in inner:
|
|
72
|
+
raise DeckParseError(f"{inner_ctx}: nested groups are not allowed")
|
|
73
|
+
slide = _parse_slide(inner, deck_dir, flat_idx, inner_ctx)
|
|
74
|
+
slides.append(slide)
|
|
75
|
+
group_slide_ids.append(slide.id)
|
|
76
|
+
flat_idx += 1
|
|
77
|
+
|
|
78
|
+
groups.append(SlideGroup(label=label, slide_ids=tuple(group_slide_ids)))
|
|
79
|
+
else:
|
|
80
|
+
slide = _parse_slide(item, deck_dir, flat_idx, ctx)
|
|
81
|
+
slides.append(slide)
|
|
82
|
+
flat_idx += 1
|
|
83
|
+
|
|
84
|
+
return tuple(slides), tuple(groups)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _require_list(d: dict, key: str, deck_path: Path) -> list:
|
|
88
|
+
if key not in d:
|
|
89
|
+
raise DeckParseError(f"{deck_path}: missing required field '{key}'")
|
|
90
|
+
v = d[key]
|
|
91
|
+
if not isinstance(v, list):
|
|
92
|
+
raise DeckParseError(f"{deck_path}: field '{key}' must be a list, got {type(v).__name__}")
|
|
93
|
+
return v
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_slide(raw: Any, deck_dir: Path, idx: int, ctx: str | None = None) -> Slide:
|
|
97
|
+
if ctx is None:
|
|
98
|
+
ctx = f"slides[{idx}]"
|
|
99
|
+
if not isinstance(raw, dict):
|
|
100
|
+
raise DeckParseError(f"{ctx}: must be an object, got {type(raw).__name__}")
|
|
101
|
+
|
|
102
|
+
slide_id = _require_str(raw, "id", ctx)
|
|
103
|
+
position = _parse_position(raw, ctx)
|
|
104
|
+
source_raw = _require_str(raw, "source", ctx)
|
|
105
|
+
|
|
106
|
+
source = (deck_dir / source_raw).resolve()
|
|
107
|
+
return Slide(id=slide_id, position=position, source=source)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_position(raw_slide: dict, ctx: str) -> Position:
|
|
111
|
+
if "position" not in raw_slide:
|
|
112
|
+
raise DeckParseError(f"{ctx}: missing required field 'position'")
|
|
113
|
+
raw_pos = raw_slide["position"]
|
|
114
|
+
if not isinstance(raw_pos, list) or len(raw_pos) != 2:
|
|
115
|
+
raise DeckParseError(f"{ctx}: 'position' must be a two-element array [x, y]")
|
|
116
|
+
x, y = raw_pos
|
|
117
|
+
if not _is_int(x) or not _is_int(y):
|
|
118
|
+
raise DeckParseError(f"{ctx}: 'position' entries must be integers")
|
|
119
|
+
return Position(x=x, y=y)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _require_str(d: dict, key: str, ctx: str) -> str:
|
|
123
|
+
if key not in d:
|
|
124
|
+
raise DeckParseError(f"{ctx}: missing required field '{key}'")
|
|
125
|
+
v = d[key]
|
|
126
|
+
if not isinstance(v, str):
|
|
127
|
+
raise DeckParseError(f"{ctx}: field '{key}' must be a string, got {type(v).__name__}")
|
|
128
|
+
return v
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_int(v: Any) -> bool:
|
|
132
|
+
# bool is a subclass of int in Python — exclude it.
|
|
133
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_edge(raw: Any, idx: int) -> RawEdge:
|
|
137
|
+
ctx = f"edges[{idx}]"
|
|
138
|
+
if not isinstance(raw, list):
|
|
139
|
+
raise DeckParseError(f"{ctx}: must be a two-element array, got {type(raw).__name__}")
|
|
140
|
+
if len(raw) != 2:
|
|
141
|
+
raise DeckParseError(f"{ctx}: must be a two-element array, got {len(raw)} elements")
|
|
142
|
+
|
|
143
|
+
return RawEdge(
|
|
144
|
+
a=_parse_endpoint(raw[0], f"{ctx}[0]"),
|
|
145
|
+
b=_parse_endpoint(raw[1], f"{ctx}[1]"),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _parse_endpoint(raw: Any, ctx: str) -> RawEndpoint:
|
|
150
|
+
if not isinstance(raw, str):
|
|
151
|
+
raise DeckParseError(f"{ctx}: endpoint must be a string, got {type(raw).__name__}")
|
|
152
|
+
|
|
153
|
+
parts = raw.split("|", 1)
|
|
154
|
+
slide_id = parts[0].strip()
|
|
155
|
+
if not slide_id:
|
|
156
|
+
raise DeckParseError(f"{ctx}: endpoint has empty slide id")
|
|
157
|
+
|
|
158
|
+
if len(parts) == 1:
|
|
159
|
+
return RawEndpoint(slide_id=slide_id, side=None)
|
|
160
|
+
|
|
161
|
+
side_str = parts[1].strip()
|
|
162
|
+
try:
|
|
163
|
+
side = Side(side_str)
|
|
164
|
+
except ValueError:
|
|
165
|
+
valid = ", ".join(s.value for s in Side)
|
|
166
|
+
raise DeckParseError(f"{ctx}: side '{side_str}' is not one of ({valid})") from None
|
|
167
|
+
|
|
168
|
+
return RawEndpoint(slide_id=slide_id, side=side)
|
scrolly/deck/schema.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""JSON Schema for the deck source format."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def deck_source_schema() -> dict:
|
|
5
|
+
"""Return a JSON Schema describing the deck JSON5 source format."""
|
|
6
|
+
return {
|
|
7
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
8
|
+
"title": "Deck source format",
|
|
9
|
+
"description": (
|
|
10
|
+
"A JSON5 file describing the presentation: a list of slides "
|
|
11
|
+
"on an integer grid, plus optional edges connecting them."
|
|
12
|
+
),
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"title": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"description": "Human-readable deck title.",
|
|
18
|
+
},
|
|
19
|
+
"slides": {
|
|
20
|
+
"type": "array",
|
|
21
|
+
"description": "The slides in the deck. Items are bare slide objects or group wrappers.",
|
|
22
|
+
"items": {
|
|
23
|
+
"oneOf": [
|
|
24
|
+
{
|
|
25
|
+
"type": "object",
|
|
26
|
+
"description": "A single slide.",
|
|
27
|
+
"properties": {
|
|
28
|
+
"id": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Unique slide identifier, referenced by edges.",
|
|
31
|
+
},
|
|
32
|
+
"position": {
|
|
33
|
+
"type": "array",
|
|
34
|
+
"description": "Integer grid position [x, y]. x increases left-to-right, y increases top-to-bottom.",
|
|
35
|
+
"items": {"type": "integer"},
|
|
36
|
+
"minItems": 2,
|
|
37
|
+
"maxItems": 2,
|
|
38
|
+
},
|
|
39
|
+
"source": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": (
|
|
42
|
+
"Path to the slide source file, relative to the deck file. "
|
|
43
|
+
"The filename suffix determines the slide type "
|
|
44
|
+
"(e.g. .static.md, .scrollimation.json, .storyboard.json)."
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
"required": ["id", "position", "source"],
|
|
49
|
+
"additionalProperties": False,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"type": "object",
|
|
53
|
+
"description": "A group wrapper containing slides. Rendered as a labelled rectangle in deck view.",
|
|
54
|
+
"properties": {
|
|
55
|
+
"group": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"description": "Group label, displayed above the group rectangle in deck view.",
|
|
58
|
+
},
|
|
59
|
+
"slides": {
|
|
60
|
+
"type": "array",
|
|
61
|
+
"description": "Slides in this group.",
|
|
62
|
+
"items": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"id": {"type": "string"},
|
|
66
|
+
"position": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"items": {"type": "integer"},
|
|
69
|
+
"minItems": 2,
|
|
70
|
+
"maxItems": 2,
|
|
71
|
+
},
|
|
72
|
+
"source": {"type": "string"},
|
|
73
|
+
},
|
|
74
|
+
"required": ["id", "position", "source"],
|
|
75
|
+
"additionalProperties": False,
|
|
76
|
+
},
|
|
77
|
+
"minItems": 1,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
"required": ["group", "slides"],
|
|
81
|
+
"additionalProperties": False,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
"edges": {
|
|
87
|
+
"type": "array",
|
|
88
|
+
"description": "Navigation edges connecting slides.",
|
|
89
|
+
"items": {
|
|
90
|
+
"type": "array",
|
|
91
|
+
"description": (
|
|
92
|
+
"A two-element array of endpoint strings. "
|
|
93
|
+
'Each endpoint is "slide_id" (side inferred from positions) '
|
|
94
|
+
'or "slide_id|side" (side explicit: top, bottom, left, right).'
|
|
95
|
+
),
|
|
96
|
+
"items": {"type": "string"},
|
|
97
|
+
"minItems": 2,
|
|
98
|
+
"maxItems": 2,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
"required": ["slides"],
|
|
103
|
+
"additionalProperties": False,
|
|
104
|
+
}
|