diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b5ea3a..11d834f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,4 +12,9 @@ All notable changes to this project are documented here. The format follows
- Five layouts: `title`, `bullets`, `split`, `steps` and `table`.
- Built-in `midnight` and `paper` themes. Custom themes are CSS files that set akceo's tokens.
- PNG, JPEG and WebP images are shrunk and embedded; SVG is embedded as-is.
-- Build errors name the file, line and slide.
+- Deck errors name the file, line and slide; theme and image errors name the file.
+- `akceo viewer` writes `md-viewer.html`, a speaker-notes viewer for a second browser tab. In
+ Chrome and Edge it refreshes live when the notes file changes.
+- Example speaker notes for the demo deck.
+- Docs: the speaker-notes setup, and how Akceo works (plain terms, user experience, internals),
+ with Mermaid diagrams.
diff --git a/CLAUDE.md b/CLAUDE.md
index b74b366..7acacd8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,10 +12,14 @@ runtime dependency.
- `src/akceo/files.py`: reads user-supplied text files, turning read failures into `DeckError`.
- `src/akceo/cli.py`: the `akceo` command.
- `src/akceo/assets/`: the page template, `base.css` (layout rules; all colors and fonts come from
- theme tokens), and `deck.js` (navigation).
+ theme tokens), `deck.js` (navigation), and `md-viewer.html` (the standalone speaker-notes
+ viewer that `akceo viewer` writes out).
- `src/akceo/themes/`: built-in themes, one CSS file each. The first-line comment is the
description `akceo themes` prints.
-- `examples/demo/`: a deck that uses every layout. The end-to-end test builds it.
+- `examples/demo/`: a deck that uses every layout, and its speaker notes. The end-to-end test
+ builds the deck.
+- `docs/`: `syntax.md` (format reference), `speaker-notes.md` (notes setup), `how-it-works.md`
+ (design, with Mermaid diagrams), `branding.md`.
## Commands
@@ -33,4 +37,7 @@ uv run akceo build examples/demo/deck.md
- `docs/syntax.md` is the format reference. Update it in the same change as any parser, renderer
or token change.
- A new theme token goes in `themes.TOKENS`, every built-in theme, and `docs/syntax.md`.
+- `docs/how-it-works.md` describes the modules and pipeline. Update it when either changes.
+- Keep JavaScript escapes such as `\u0000` as text in the assets. A raw control character breaks
+ the page, and a test checks for it.
- Keep the `Unreleased` section of `CHANGELOG.md` current.
diff --git a/README.md b/README.md
index fcea46c..b1eef01 100644
--- a/README.md
+++ b/README.md
@@ -98,6 +98,26 @@ that sets akceo's color and font tokens. The simplest start is a copy of
[`src/akceo/themes/midnight.css`](src/akceo/themes/midnight.css). The token list is in
[docs/syntax.md](docs/syntax.md#themes).
+## Speaker notes
+
+Keep your notes in a Markdown file and read them in a second browser tab, while you share only
+the deck tab in your call.
+
+```sh
+akceo viewer # writes md-viewer.html into the current folder
+```
+
+Open `md-viewer.html` next to your deck, for example with Chrome's split view, and open your
+notes file in it. In Chrome and Edge it refreshes by itself when you save the notes.
+`examples/demo/speaker-notes.md` goes with the demo deck. The setup is in
+[docs/speaker-notes.md](docs/speaker-notes.md).
+
+## How it works
+
+[docs/how-it-works.md](docs/how-it-works.md) explains Akceo at three levels: in plain terms,
+from the user's side, and under the hood. It has diagrams of the build pipeline, the data model
+and the notes viewer.
+
## Development
```sh
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
new file mode 100644
index 0000000..91fceff
--- /dev/null
+++ b/docs/how-it-works.md
@@ -0,0 +1,393 @@
+# How Akceo works
+
+This document explains Akceo at three levels. Read as far as you need.
+
+1. [In plain terms](#in-plain-terms): what Akceo is and why you might use it.
+2. [The user's experience](#the-users-experience): what it's like to write, build and present a
+ deck.
+3. [Under the hood](#under-the-hood): how the code turns Markdown into a deck.
+
+The exact file format is in [syntax.md](syntax.md), and the notes setup is in
+[speaker-notes.md](speaker-notes.md).
+
+---
+
+## In plain terms
+
+Akceo turns a text outline into a slide deck.
+
+You write your slides as plain text, with a few simple marks for headings, lists and emphasis.
+Akceo reads that text and produces one file that is the whole presentation. Double-click the
+file and it opens in a web browser, ready to present.
+
+```mermaid
+flowchart LR
+ outline["Your outline (a text file)"] --> akceo(["Akceo"])
+ look["A look (a theme)"] --> akceo
+ pictures["Your pictures"] --> akceo
+ akceo --> deck["One presentation file (opens in any browser)"]
+```
+
+**Why work this way?**
+
+- **You write, Akceo designs.** There are no boxes to drag or fonts to match. Every slide
+ follows the same layout rules, so the deck looks consistent without effort.
+- **One file, no surprises.** Pictures are packed inside the file. It works offline and on any
+ computer, and nothing goes missing when you email it.
+- **Change the look in one step.** The same outline can be rebuilt in a dark or light theme, or
+ in your own brand colors.
+- **Text is easy to keep.** A plain-text deck can be versioned, compared, reused and reviewed
+ like any other document.
+- **Private notes.** Your speaker notes open in a separate tab that the audience never sees.
+
+**Who it's for:** people who present often and would rather write than lay out slides. Think
+engineers, founders, and anyone giving a design review or a pitch.
+
+**What it isn't:** a drawing tool. Akceo has five fixed layouts, and that restraint is the point.
+If a slide needs free-form design, make that one image in another tool and drop it into a
+`split` slide.
+
+---
+
+## The user's experience
+
+### The whole journey
+
+```mermaid
+journey
+ title Making and giving a talk with Akceo
+ section Write
+ Outline slides in deck.md: 5: Presenter
+ Add images and pick a theme: 4: Presenter
+ Write speaker notes: 4: Presenter
+ section Build
+ Run akceo build: 5: Presenter
+ Fix any error it names: 3: Presenter
+ section Rehearse
+ Open deck.html and click through: 5: Presenter
+ Edit, rebuild, refresh: 4: Presenter
+ section Present
+ Open notes beside the deck: 4: Presenter
+ Share the deck tab in the call: 5: Presenter, Audience
+```
+
+### Writing
+
+A deck is one Markdown file. A short config block at the top sets the title and theme. Then each
+slide follows a `---` line, and a few header lines choose its layout:
+
+```markdown
+title: Q3 Review
+theme: midnight
+
+---
+
+layout: title
+kicker: Q3 review
+
+# Shipping faster
+
+---
+
+kicker: The problem
+
+## Builds take too long
+
+- **Full rebuilds** on every change.
+- CI queues back up at ==peak hours==.
+```
+
+There are five layouts: `title`, `bullets`, `split` (image beside text), `steps` and `table`.
+The inline marks are small: bold, a strong color, an accent color, dimmed asides and code.
+
+### Building
+
+```sh
+akceo build deck.md
+```
+
+It prints the output file, its size and the slide count:
+
+```text
+wrote deck.html (12 KB, 7 slides)
+```
+
+If something is wrong, the build stops and says exactly where, instead of producing a broken
+deck:
+
+```text
+akceo: deck.md:14: slide 3: the steps layout needs a 1. list
+```
+
+The message gives the file, the line, the slide number and what to change. Typos in keys, a key
+on the wrong layout, a missing image and content a layout can't show are all caught this way.
+
+### The edit loop
+
+```mermaid
+flowchart LR
+ edit["Edit deck.md"] --> build["akceo build deck.md"]
+ build -->|"error at file:line"| edit
+ build -->|"wrote deck.html"| refresh["Refresh the browser tab"]
+ refresh --> edit
+```
+
+### Presenting
+
+Open `deck.html` in a browser.
+
+| Action | Keys |
+| --- | --- |
+| Next slide | `→`, `Space`, `PageDown`, or click the right half |
+| Previous slide | `←`, `PageUp`, or click the left half |
+| First or last slide | `Home`, `End` |
+| Full screen | `F` |
+
+A thin progress bar runs along the bottom, with a slide counter in the corner. Selecting text
+doesn't change slides, so you can copy from a slide mid-talk.
+
+### Presenting with private notes
+
+Your notes live in a Markdown file. You read them in `md-viewer.html`, a small page that
+`akceo viewer` writes out. Put the deck and the viewer side by side in one Chrome window, and
+share only the deck tab in your call. When you save the notes file, the viewer updates by itself.
+[speaker-notes.md](speaker-notes.md) walks through the setup.
+
+```mermaid
+sequenceDiagram
+ actor P as Presenter
+ participant D as Deck tab
+ participant V as Viewer tab
+ participant M as Meet or Zoom
+ actor A as Audience
+ P->>D: open deck.html
+ P->>V: open md-viewer.html, then the notes file
+ P->>M: share the deck tab only
+ M->>A: the slides, nothing else
+ loop during the talk
+ P->>D: arrow keys
+ P->>V: glance at notes, scroll
+ end
+```
+
+---
+
+## Under the hood
+
+### Design goals
+
+- **Self-contained output.** The built page references no external file or URL. CSS,
+ JavaScript and images are all inside it.
+- **One runtime dependency.** Pillow, for shrinking images. Everything else is the Python
+ standard library, and the page uses plain JavaScript with no framework.
+- **Fail loudly and precisely.** Input is checked before anything is written. Every error names
+ the file it's about, and errors in the deck also give the line and slide.
+- **Content and look are separate.** The deck says what's on each slide; the theme alone decides
+ colors and fonts.
+
+### Modules
+
+```mermaid
+flowchart TD
+ cli["cli.py akceo build / themes / viewer"] --> render["render.py build()"]
+ render --> parse["parse.py load(), validation"]
+ parse --> files["files.py read_text()"]
+ render --> themes["themes.py load(), token check"]
+ themes --> files
+ render --> images["images.py data_uri()"]
+ render --> assets[["assets/ page.html · base.css · deck.js"]]
+ themes --> builtin[["themes/ midnight.css · paper.css"]]
+ cli --> viewer[["assets/md-viewer.html"]]
+ parse -.->|raises| err["errors.py DeckError"]
+ themes -.->|raises| err
+ images -.->|raises| err
+ files -.->|raises| err
+```
+
+| Module | Job |
+| --- | --- |
+| `cli.py` | Parses arguments, runs a command, writes the output, turns `DeckError` into a message and exit code 1 |
+| `render.py` | Runs the build: loads the deck and theme, embeds images, renders each slide, fills the page template |
+| `parse.py` | Turns Markdown into a validated `Deck`. All input rules live here. |
+| `themes.py` | Finds a theme by name or path and checks that it sets every token |
+| `images.py` | Turns an image file into a `data:` URI, shrinking raster images |
+| `files.py` | Reads user files, turning read and decode failures into `DeckError` |
+| `errors.py` | `DeckError`, the one exception type the CLI reports to the user |
+
+### The build pipeline
+
+```mermaid
+sequenceDiagram
+ participant CLI as cli.py
+ participant R as render.build
+ participant P as parse.load
+ participant T as themes.load
+ participant I as images.data_uri
+ CLI->>R: deck path, optional --theme
+ R->>P: read and parse deck.md
+ P-->>R: Deck (config + slides)
+ R->>T: theme from --theme, else the deck's theme:, else midnight
+ T-->>R: theme CSS
+ loop each split slide
+ R->>I: image path, image-max
+ I-->>R: data URI
+ end
+ R->>R: render slides, fill page.html
+ R-->>CLI: HTML, slide count
+ CLI->>CLI: write deck.html, print summary
+```
+
+### Parsing
+
+Parsing runs in stages, each working on the output of the one before:
+
+```mermaid
+flowchart LR
+ text["deck.md text"] --> chunks["Split on --- lines (keeps line numbers)"]
+ chunks --> config["First chunk: config block"]
+ chunks --> slides["Each other chunk: a slide"]
+ slides --> header["Header lines key: value"]
+ slides --> body["Body lines"]
+ body --> logical["Join continuations (2-space indent, trailing \)"]
+ logical --> blocks["Group into blocks h1 h2 phase ul ol lead table note para"]
+ header --> check{"Validate against the layout's contract"}
+ blocks --> check
+ check -->|ok| slide["Slide"]
+ check -->|fail| error["DeckError file:line: slide N: …"]
+```
+
+Each layout has a contract, set in data at the top of `parse.py`:
+
+- the header keys it accepts (`LAYOUT_KEYS`)
+- the blocks it renders, and whether each may repeat (`LAYOUT_BLOCKS`)
+- what it requires (`REQUIRED_KEYS`, `REQUIRED_BLOCKS`)
+
+Anything outside the contract is an error rather than something silently dropped.
+
+The parsed result is a small, immutable data model:
+
+```mermaid
+classDiagram
+ class Deck {
+ path: Path
+ config: dict
+ slides: tuple~Slide~
+ title()
+ error(slide, message)
+ }
+ class Slide {
+ number: int
+ line: int
+ meta: dict
+ blocks: tuple~Block~
+ layout()
+ first(kind)
+ flag(key)
+ }
+ class Block {
+ kind: str
+ text: str
+ body: str
+ items: tuple
+ rows: tuple
+ }
+ Deck "1" --> "many" Slide
+ Slide "1" --> "many" Block
+```
+
+### Rendering
+
+`render_slide` writes one `` per slide, choosing markup by layout. Inline
+text goes through `inline()` in a fixed order. This order is why markup inside backticks stays
+literal and why raw HTML is always escaped:
+
+1. Set aside `` `code` `` spans, so nothing else touches them.
+2. Escape HTML (`&`, `<`, `>`).
+3. Apply `***strong***`, `**bold**`, `==accent==` and `((dim))`.
+4. Put the code spans back, escaped.
+5. Turn hard line breaks into ` `.
+
+The page comes from `assets/page.html`, which has four placeholders filled in one pass: title,
+styles, slides and script. Filling them in one pass means slide text that happens to contain a
+placeholder name is never replaced.
+
+### Themes
+
+```mermaid
+flowchart LR
+ base["base.css layout rules, colors via var(--token)"] --> pagestyle["The page's style block"]
+ theme["theme CSS sets --bg, --accent, … may override rules"] --> pagestyle
+ pagestyle --> page["deck.html"]
+```
+
+`base.css` holds only layout. Every color and font in it comes from a CSS custom property, such
+as `var(--accent)`. A theme is a CSS file that sets those properties. It's added after
+`base.css`, so it can also override any layout rule. `themes.load` rejects a theme that leaves
+out a token, or that contains ` exists{"File exists?"}
+ exists -->|no| e1["DeckError: image not found"]
+ exists -->|yes| svg{"SVG?"}
+ svg -->|yes| raw["Embed the bytes unchanged"]
+ svg -->|no| fmt{"PNG, JPEG or WebP?"}
+ fmt -->|no| e2["DeckError: unsupported format"]
+ fmt -->|yes| fix["Rotate upright from EXIF, shrink to image-max, never enlarge"]
+ fix --> save["Re-save in the same format (JPEG and WebP at quality 90)"]
+ raw --> uri["base64 data: URI in the img src"]
+ save --> uri
+```
+
+Images are processed on every build, so replacing an image file and rebuilding just works.
+
+### Errors
+
+Every problem the user can fix is a `DeckError`, with a message in one of these forms:
+
+```text
+deck.md:14: slide 3: the steps layout needs a 1. list
+deck.md:2: unknown config key 'diagrams' (expected one of: title, theme, images)
+theme brand.css doesn't set: --figure-bg
+```
+
+`cli.main` catches `DeckError`, prints `akceo: ` to stderr and exits with code 1.
+Anything else is a bug in Akceo, and it shows a normal Python traceback.
+
+### The page at runtime
+
+`deck.js` is small. It shows one slide at a time by toggling an `active` class,
+moves the progress bar and counter, and maps keys and clicks to next and previous. A click is
+ignored while text is selected.
+
+### The notes viewer
+
+`md-viewer.html` is a separate, self-contained page with its own small Markdown renderer. Live
+refresh uses Chrome's File System Access API. That API is available to pages opened from disk,
+so no server is needed.
+
+```mermaid
+sequenceDiagram
+ actor P as Presenter
+ participant V as md-viewer.html
+ participant C as Chrome file access
+ participant F as speaker-notes.md
+ P->>V: click Open .md (or drop the file)
+ V->>C: showOpenFilePicker()
+ C-->>V: file handle
+ V->>F: read, render, show "● watching"
+ loop every 1.5 seconds
+ V->>C: handle.getFile()
+ C-->>V: file with lastModified
+ alt lastModified changed
+ V->>F: read and re-render, keep scroll position
+ end
+ end
+ Note over V,F: If the read fails, show "file unavailable · open it again"
+```
+
+In browsers without that API, the viewer still opens files, but you drop the file again to
+refresh.
diff --git a/docs/speaker-notes.md b/docs/speaker-notes.md
new file mode 100644
index 0000000..d022f25
--- /dev/null
+++ b/docs/speaker-notes.md
@@ -0,0 +1,108 @@
+# Speaker notes
+
+An Akceo deck has no built-in presenter view. You keep your notes in a Markdown file and read
+them in a second browser tab with `md-viewer.html`. In a video call you share only the deck tab,
+so your notes stay private.
+
+```mermaid
+flowchart LR
+ subgraph window["Your Chrome window, in split view"]
+ deck["Tab 1: deck.html the slides"]
+ notes["Tab 2: md-viewer.html showing speaker-notes.md"]
+ end
+ deck -->|"shared tab"| meeting["Google Meet or Zoom"]
+ meeting --> audience["Audience sees only the slides"]
+ file[("speaker-notes.md on disk")] -.->|"refreshes when you save"| notes
+```
+
+## Set it up
+
+1. **Write your notes** in a Markdown file next to your deck, for example `speaker-notes.md`.
+ The [notes format](#notes-format) below has a suggested layout.
+2. **Get the viewer.** `akceo viewer` writes `md-viewer.html` into the current folder. Use
+ `-o` to put it somewhere else. You only need one copy; it can open any notes file.
+3. **Open the deck** (`deck.html`) in Chrome.
+4. **Open the viewer** (`md-viewer.html`) in a second tab. Click **Open .md** and pick your notes
+ file, or drag the file onto the page.
+5. **Put the tabs side by side** with Chrome's split view, so both sit in one window.
+6. **Present.** Share the deck tab in your meeting, and read your notes from the other half of
+ the window.
+
+### Try it with the demo
+
+```sh
+akceo build examples/demo/deck.md
+akceo viewer -o examples/demo
+```
+
+Open `examples/demo/deck.html` and `examples/demo/md-viewer.html` in two tabs. In the viewer,
+open `examples/demo/speaker-notes.md`. Its sections match the demo's seven slides.
+
+## Sharing in a meeting
+
+The idea is to share the one tab that holds the slides, not your whole screen.
+
+- **Google Meet** in Chrome can share a single tab. Choose to present a tab and pick the deck.
+- **Zoom's desktop app** shares a screen or a window rather than a tab. With split view, the
+ window includes your notes. Either share just the part of the screen that holds the deck, or
+ keep the deck in its own window and share that.
+
+## Live refresh
+
+In Chrome and Edge, the viewer watches the file you opened. When you save the notes, the page
+updates within about two seconds and keeps your scroll position. The header shows
+**● watching** while this is on. It works when the viewer is opened straight from disk, with no
+web server.
+
+- If the file is moved or deleted, the header shows **file unavailable · open it again**.
+- Reloading the viewer tab forgets the file. Open it again.
+- Other browsers can't watch files. Drop the file on the page again to refresh.
+
+**A−** and **A+** change the text size, and the viewer remembers the size for next time.
+
+## Notes format
+
+The viewer renders a common subset of Markdown, listed below. This layout reads well in it:
+
+```markdown
+# Speaker Notes · My talk
+
+---
+
+### 1 · Opening *(0:30)*
+What to say on the first slide.
+
+### 2 · The problem *(1:00)*
+- A point to hit
+- Another point
+```
+
+- One `###` heading per slide, numbered to match the deck, makes it easy to keep your place.
+- An italic part in a heading, like `*(0:30)*`, shows in small amber type. It's a good spot for
+ a time budget.
+
+The viewer renders:
+- headings
+- paragraphs
+- **bold**, *italic* and `code`
+- links
+- images
+- bulleted and numbered lists, nested by indenting
+- tables
+- quotes
+- fenced code blocks
+- horizontal rules
+
+Raw HTML shows as plain text, and other Markdown extensions, such as strikethrough, task lists
+and footnotes, aren't rendered.
+
+Only `http`, `https`, `mailto` and relative links become clickable. Links open in a new tab.
+
+Images use ``. A relative path resolves from the folder that holds `md-viewer.html`,
+not from the notes file, because the browser doesn't tell the viewer where the notes file lives.
+So keep the viewer next to your notes: `akceo viewer -o `. `http(s)` and
+`data:image/` sources work too. Any other source shows the alt text instead.
+
+## Limits
+
+- The notes don't follow the slides. Scroll the viewer as you go.
diff --git a/examples/demo/speaker-notes.md b/examples/demo/speaker-notes.md
new file mode 100644
index 0000000..1855102
--- /dev/null
+++ b/examples/demo/speaker-notes.md
@@ -0,0 +1,41 @@
+# Speaker Notes · Akceo demo
+
+Open this file in `md-viewer.html` in a second tab. Share only the deck tab.
+
+---
+
+### 1 · Decks at the speed of text *(0:20)*
+Open with the promise: a deck is a text file, and one command turns it into a presentation.
+
+### 2 · Slides are structured text *(0:45)*
+Three points, in order:
+
+1. **Markdown**: write in any editor, keep it in git, review changes in a diff.
+2. **One file**: images are embedded, so there's nothing to lose when you email it.
+3. **Anywhere**: it opens in any browser, even with no network.
+
+If asked: this slide is the `bullets` layout, the default when a slide doesn't name one.
+
+### 3 · A small set of marks *(0:40)*
+Walk the list top to bottom. Each item shows the syntax and the result side by side.
+
+- `==accent==` is the one to point out; it's how a lead line gets its color.
+- The last item shows that a long line can wrap in the source without breaking on the slide.
+
+### 4 · One command, one file *(0:40)*
+Point at the diagram: the deck and a theme go in, one HTML file comes out.
+
+The three phases on the right are the whole workflow. Mention that images are shrunk at build time, so a big screenshot doesn't make a big deck.
+
+### 5 · From zero to a deck *(0:30)*
+Read the four steps as a loop, not a one-time setup. After the first install it's write, build, present.
+
+### 6 · Five layouts *(0:30)*
+There are only five layouts, on purpose. Most slides are `bullets`; `split` is for a diagram with commentary.
+
+> The muted last column is `dim-last-column: yes` on this slide.
+
+### 7 · Same deck, any look *(0:20)*
+Close by rebuilding with `--theme paper` live, if time allows. The content doesn't change, only the look.
+
+---
diff --git a/src/akceo/assets/md-viewer.html b/src/akceo/assets/md-viewer.html
new file mode 100644
index 0000000..b08fe14
--- /dev/null
+++ b/src/akceo/assets/md-viewer.html
@@ -0,0 +1,257 @@
+
+
+
+
+
+Markdown Viewer
+
+
+
+
+ No file loaded
+
+
+
+
+
+
+
+
Open or drop a Markdown file
+
+
+
+
+
Drop to open
+
+
+
+
diff --git a/src/akceo/cli.py b/src/akceo/cli.py
index 7b8b0da..6d42a05 100644
--- a/src/akceo/cli.py
+++ b/src/akceo/cli.py
@@ -9,6 +9,8 @@
from akceo import render, themes
from akceo.errors import DeckError
+VIEWER = "md-viewer.html"
+
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
@@ -23,6 +25,13 @@ def _parser() -> argparse.ArgumentParser:
build.add_argument("-t", "--theme", help="built-in theme name or path to a .css file (overrides theme:)")
commands.add_parser("themes", help="list the built-in themes")
+
+ viewer = commands.add_parser(
+ "viewer", help="write md-viewer.html, a live Markdown viewer for speaker notes"
+ )
+ viewer.add_argument(
+ "-o", "--out", type=Path, default=Path(VIEWER), help=f"output file or folder (default: ./{VIEWER})"
+ )
return parser
@@ -31,6 +40,8 @@ def main(argv: Sequence[str] | None = None) -> int:
try:
if args.command == "build":
_build(args.deck, args.out, args.theme)
+ elif args.command == "viewer":
+ _write_viewer(args.out)
else:
_list_themes()
except DeckError as e:
@@ -42,12 +53,23 @@ def main(argv: Sequence[str] | None = None) -> int:
def _build(deck: Path, out: Path | None, theme: str | None) -> None:
page, count = render.build(deck, theme)
out = out or deck.with_suffix(".html")
+ _write(out, page)
+ noun = "slide" if count == 1 else "slides"
+ print(f"wrote {out} ({out.stat().st_size / 1024:.0f} KB, {count} {noun})")
+
+
+def _write_viewer(out: Path) -> None:
+ if out.is_dir():
+ out = out / VIEWER
+ _write(out, (render.ASSETS / VIEWER).read_text(encoding="utf-8"))
+ print(f"wrote {out}")
+
+
+def _write(out: Path, text: str) -> None:
try:
- out.write_text(page, encoding="utf-8")
+ out.write_text(text, encoding="utf-8")
except OSError as e:
raise DeckError(f"{out}: {e.strerror}") from None
- noun = "slide" if count == 1 else "slides"
- print(f"wrote {out} ({out.stat().st_size / 1024:.0f} KB, {count} {noun})")
def _list_themes() -> None:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 119fde3..e0a7412 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -7,7 +7,9 @@
from akceo.cli import main
-DEMO = Path(__file__).parents[1] / "examples" / "demo"
+ROOT = Path(__file__).parents[1]
+DEMO = ROOT / "examples" / "demo"
+EXTERNAL_REF = re.compile(r"""(src|href)=["']?(https?:|//|\.{0,2}/)|= 6
+ for path in paths:
+ text = path.read_text(encoding="utf-8")
+ bad = {hex(ord(c)) for c in text if (ord(c) < 32 and c not in "\n\t") or 0xE000 <= ord(c) <= 0xF8FF}
+ assert not bad, f"{path.name}: {sorted(bad)}"