Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/publish-decks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ on:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
export_pdfs:
description: Also export a PDF per deck (dormant by default)
type: boolean
default: false

permissions:
contents: read
Expand Down Expand Up @@ -46,13 +51,17 @@ jobs:

# Serving dist/ (not the source) means the PDFs are rendered from the exact
# bytes we ship, so a broken vendored asset fails the build here.
# PDF export is dormant: it only runs from a manual "Run workflow" with
# export_pdfs ticked. Push and pull_request runs skip these three steps.
- name: Serve built site
if: inputs.export_pdfs
working-directory: dist
run: |
npx --yes http-server . -p "$DECK_PORT" --silent &
npx --yes wait-on -t 60000 "http://127.0.0.1:$DECK_PORT/index.html"

- name: Export a PDF per deck
if: inputs.export_pdfs
working-directory: dist
run: |
slugs=$(node -e "console.log(require('./decks.json').decks.map(d => d.slug).join('\n'))")
Expand All @@ -78,6 +87,7 @@ jobs:
if-no-files-found: error

- name: Upload PDFs
if: inputs.export_pdfs
uses: actions/upload-artifact@v4
with:
name: deck-pdfs
Expand Down
102 changes: 97 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,33 @@ built `standalone/` files are exempt, since everything is inlined into them.
decks-as-code/
├── index.html landing page, lists every deck
├── deck.html the player: deck.html?d=<slug>
├── convert.html .pptx to localizable HTML, in the browser
├── decks.json generated listing, don't hand-edit
├── assets/theme.css shared brand styling for all decks
├── assets/ theme.css (brand), pptx-slide.css (converted slides),
│ deck-i18n.js (language swap, fit-to-box)
├── docs/string-table.md the localization contract
├── tools/
│ ├── build-manifest.mjs regenerates decks.json
│ ├── build-site.mjs builds dist/: offline site plus single-file decks
│ └── import-pptx.mjs converts a .pptx into a deck folder
│ ├── import-pptx.mjs converts a .pptx into a Markdown deck folder
│ ├── pptx-to-html.mjs converts a .pptx into an HTML deck folder (pptx-html.mjs)
│ ├── translate.mjs translates a deck's strings.json with an LLM
│ ├── translate-core.mjs hashing, validation, batching, retry (used by translate)
│ └── llm-provider.mjs provider interface: copilot, openai-compatible, mock
└── decks/
└── <slug>/
├── slides.md the deck. The only file you edit day to day.
├── deck.json title, description, author, date, tags, theme
└── images/ optional, referenced as ![](images/x.png)
(decks converted to HTML have slides.html and strings.json instead of slides.md)
```

| URL | Serves |
| --- | --- |
| `/` | Landing page listing all decks |
| `/deck.html?d=<slug>` | That deck, full screen |
| `/decks/<slug>/deck.pdf` | PDF export, once built |
| `/deck.html?d=<slug>&lang=de` | An HTML deck in a translation (needs `strings.de.json`) |
| `/decks/<slug>/deck.pdf` | PDF export, once built (off by default) |

---

Expand Down Expand Up @@ -142,6 +151,85 @@ Options:

---

## Convert a PowerPoint to localizable HTML

A second path, for when the slides must look like the original and every word has to
be translatable. Instead of Markdown, a `.pptx` becomes positioned HTML: one fixed
canvas per slide, shapes placed where PowerPoint put them, and all text kept as real
text.

Open `convert.html` on the served site (or `node tools/pptx-to-html.mjs talk.pptx
--slug my-talk`). It reads the file in your browser, never uploads it, and lets you:

- **Preview** the slides, with speaker notes.
- **Look through** the generated `slides.html` and the string table, and see what the
converter could not handle.
- **Download** a single HTML file you can double-click, or the deck folder as a ZIP.

The folder holds `slides.html`, `strings.json` and `images/`. `strings.json` lists
every paragraph, speaker note and alt text under a stable ID, in the format described
in [`docs/string-table.md`](docs/string-table.md); translations are `strings.<lang>.json`
files in the same folder. A language picker appears in the corner of the deck (and the
single-file version) when translations exist; `?lang=<code>` opens one directly.
Longer translated text shrinks to fit its box (down to 60%); add `&review` to the
address to outline boxes that still overflow.

What is kept: every paragraph (including hidden-slide text and notes), bold, italic,
underline, links, bullets and numbering, fonts and colors, positions, rotation, groups,
pictures with cropping, tables, gradients, and the common shapes and connectors with
arrowheads. SmartArt is rendered from its stored drawing when the file has one.

What is not: text inside pictures (it cannot be translated), charts (shown as a
placeholder), EMF/WMF pictures (browsers cannot draw them), animations, transitions,
and shadows or other effects. Shapes with an unusual preset are drawn as rectangles.
Everything the converter skipped or approximated is listed under "Needs a look".

Code paragraphs (monospace text) stay on the slide but are left out of the string
table, so nothing translates them.

---

## Translate a deck

`tools/translate.mjs` translates a deck's string table (`decks/<slug>/strings.json`,
see [`docs/string-table.md`](docs/string-table.md)) into `strings.<lang>.json` files.
Node 18+, no dependencies.

```powershell
node tools\translate.mjs my-talk --to de,pt-BR,ar # translate
node tools\translate.mjs my-talk --to de --dry-run # show what would change
node tools\translate.mjs my-talk --to de --force # retranslate everything
node tools\translate.mjs my-talk --to de --provider mock # offline fake, no LLM
node --test # run the tests (no network)
```

- **Incremental.** Only strings that are missing or whose source text changed (the
`src` hash no longer matches) are sent to the model. `--force` redoes all of them.
- **Validated.** Every translation must keep the same inline tags (in matching pairs),
keep `href` values, and be non-empty. A failing batch is retried once with the error
fed back; strings that still fail are skipped and listed, the file stays valid, and
the viewer falls back to the source text for them. Exit code `2` means "done, but
some strings were skipped"; re-run to retry them.
- **Provider.** `--provider copilot` (default) runs the GitHub Copilot CLI in
non-interactive mode, so it uses the models in your Copilot subscription: no API key,
nothing to configure beyond signing in once by running `copilot`. Tools, MCP servers and
custom instructions are switched off, so the model can only answer. Optional:
`--model <name>` (otherwise the CLI's default), `LLM_REASONING_EFFORT` (default `low`),
`COPILOT_BIN` if `copilot` is not on your PATH. The five-slide sample takes under a
minute per language; a 50-slide deck takes a few minutes.
`--provider openai` talks to any OpenAI-compatible
`/chat/completions` endpoint. Configure it with environment variables (the key is
never a flag, never logged, never written): `LLM_API_KEY` (or `OPENAI_API_KEY`),
`LLM_MODEL` (or `--model`), and optionally `LLM_BASE_URL` (or `--base-url`; default
`https://api.openai.com/v1`). For Azure OpenAI / Foundry use the
`https://<resource>.openai.azure.com/openai/v1` base URL, the deployment name as the
model, and `LLM_AUTH_HEADER=api-key`. Set `LLM_JSON_MODE=off` if a host rejects
`response_format`. 429s honour `retry-after`. Providers live in
`tools/llm-provider.mjs`; adding one means returning `{ name, complete({messages}) }`.
GitHub Models was retired on 2026-07-30, so it is not supported.

---

## Writing slides

`slides.md` is ordinary Markdown with three separator conventions:
Expand Down Expand Up @@ -181,8 +269,12 @@ from your file system and they now say so rather than failing silently. To run t
whole site locally, serve `dist/` on a port as shown under "Run it locally".

`.github/workflows/publish-decks.yml` runs on every push and pull request. It builds
`dist/`, exports a PDF per deck, and uploads two artifacts: `deck-standalone` and
`deck-pdfs`. On `main` it also deploys `dist/` to GitHub Pages.
`dist/` and uploads the `deck-standalone` artifact. On `main` it also deploys `dist/`
to GitHub Pages.

**PDF export is dormant.** The workflow no longer exports PDFs on push or pull
request. The steps are still there: run the workflow manually (Actions → Publish
decks → Run workflow) and tick `export_pdfs` to get a `deck-pdfs` artifact.

**GitHub Pages.** This repo is public, so Pages is enabled and every push to `main`
republishes <https://ivorb.github.io/decks-as-code/>. Settings → Pages → Source is set
Expand Down
175 changes: 175 additions & 0 deletions assets/deck-i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/*
* Swaps localized text into a deck converted from PowerPoint.
*
* Every localizable node carries data-i18n="<id>" (or data-i18n-alt for image
* alt text). A translation is a table { strings: { "<id>": { text } } }. Anything
* missing keeps its source text, so a deck never renders blank.
* See docs/string-table.md.
*
* A classic script, shared by deck.html and the standalone single-file build.
*/
(function () {
var RTL = { ar: 1, he: 1, fa: 1, ur: 1 };
var ALLOWED = { B: 1, I: 1, U: 1, SUP: 1, SUB: 1, CODE: 1, BR: 1, A: 1 };

/** Rebuild a translation from the contract's inline tags only; drop everything else. */
function safeFragment(html) {
var tpl = document.createElement('template');
tpl.innerHTML = html;
var frag = document.createDocumentFragment();
(function clean(src, dest) {
for (var i = 0; i < src.childNodes.length; i++) {
var n = src.childNodes[i];
if (n.nodeType === 3) dest.appendChild(document.createTextNode(n.textContent));
else if (n.nodeType === 1 && ALLOWED[n.tagName]) {
var el = document.createElement(n.tagName);
if (n.tagName === 'A') {
var href = n.getAttribute('href') || '';
if (/^(https?:|mailto:)/i.test(href)) el.setAttribute('href', href);
}
clean(n, el);
dest.appendChild(el);
} else if (n.nodeType === 1) clean(n, dest);
}
})(tpl.content, frag);
return frag;
}

function plainText(html) {
var tpl = document.createElement('template');
tpl.innerHTML = html;
return tpl.content.textContent;
}

/** Apply a translation table. `source` restores the original text first. */
function apply(table, lang) {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
if (el.dataset.i18nSrc === undefined) el.dataset.i18nSrc = el.innerHTML;
var hit = table && table.strings && table.strings[el.dataset.i18n];
if (hit && hit.text) el.replaceChildren(safeFragment(hit.text));
else el.innerHTML = el.dataset.i18nSrc;
});
document.querySelectorAll('[data-i18n-alt]').forEach(function (el) {
if (el.dataset.i18nSrc === undefined) el.dataset.i18nSrc = el.alt;
var hit = table && table.strings && table.strings[el.dataset.i18nAlt];
el.alt = hit && hit.text ? plainText(hit.text) : el.dataset.i18nSrc;
});
if (lang) {
document.documentElement.lang = lang;
document.documentElement.dir = RTL[lang.split('-')[0]] ? 'rtl' : 'ltr';
}
}

function langFromUrl() {
var lang = new URLSearchParams(window.location.search).get('lang');
return lang && /^[a-z]{2,3}(-[A-Za-z0-9]+)*$/.test(lang) ? lang : null;
}

var FIT_FLOOR = 0.6;
var FIT_STEP = 0.05;

/**
* Longer text (a translation, or a fallback font that runs wide) must not spill
* out of its box and over its neighbours. Paragraph sizes are written as
* calc(var(--fit, 1) * Npx), so shrinking a box's text is one custom property.
* Boxes that still overflow at the floor are flagged with data-overflow.
* Only visible slides can be measured, so this runs per slide on demand.
*/
function fitSlide(slide) {
if (!slide) return;
slide.querySelectorAll('.sh').forEach(function (sh) {
var tx = sh.querySelector(':scope > .tx');
if (!tx) return;
var limit = parseFloat(sh.style.minHeight);
if (!limit) return;
tx.style.removeProperty('--fit');
sh.removeAttribute('data-overflow');
var scale = 1;
while (tx.offsetHeight > limit + 1 && scale > FIT_FLOOR) {
scale = Math.round((scale - FIT_STEP) * 100) / 100;
tx.style.setProperty('--fit', String(scale));
}
if (tx.offsetHeight > limit + 1) sh.setAttribute('data-overflow', 'true');
});
slide.dataset.fitted = '1';
}

/** Fit the current slide now and on every navigation; call once after Reveal starts. */
function autoFit(reveal) {
// ?review outlines boxes whose text still overflows after shrinking.
if (new URLSearchParams(window.location.search).has('review')) document.documentElement.classList.add('deck-review');
var run = function () { fitSlide(reveal.getCurrentSlide()); };
reveal.on('ready', run);
reveal.on('slidechanged', run);
reveal.on('resize', function () {
var slide = reveal.getCurrentSlide();
if (slide) delete slide.dataset.fitted;
});
if (reveal.isReady && reveal.isReady()) run();
}

/** Forget measurements, e.g. after the language changed. */
function refit(reveal) {
document.querySelectorAll('[data-fitted]').forEach(function (s) { delete s.dataset.fitted; });
if (reveal) fitSlide(reveal.getCurrentSlide());
}

function languageName(code) {
try {
var label = new Intl.DisplayNames([code], { type: 'language' }).of(code);
return label ? label.charAt(0).toUpperCase() + label.slice(1) : code;
} catch (e) {
return code;
}
}

/**
* A language <select> in the corner of the deck. Hidden when there is nothing
* to choose. Options are named in their own language ("Français"), the source
* language comes first, and the choice is mirrored into ?lang= so the address
* can be shared.
*
* source the deck's own language tag
* languages translation tags that have a table
* load async (tag) => table
* reveal the Reveal instance, to re-fit text after a swap
*/
function mountPicker(o) {
var langs = (o.languages || []).filter(function (l) { return l !== o.source; });
if (!langs.length) return null;

var box = document.createElement('div');
box.className = 'deck-lang';
var select = document.createElement('select');
select.setAttribute('aria-label', 'Language');
[o.source].concat(langs).forEach(function (tag) {
var opt = document.createElement('option');
opt.value = tag;
opt.textContent = languageName(tag === o.source ? tag.split('-')[0] : tag);
select.appendChild(opt);
});
box.appendChild(select);
document.body.appendChild(box);

var chosen = langFromUrl();
select.value = chosen && langs.indexOf(chosen) >= 0 ? chosen : o.source;

select.addEventListener('change', async function () {
var tag = select.value;
var table = null;
if (tag !== o.source) {
try { table = await o.load(tag); } catch (e) { table = null; }
}
apply(table, tag);
if (o.reveal) refit(o.reveal);
var url = new URL(window.location.href);
if (tag === o.source) url.searchParams.delete('lang'); else url.searchParams.set('lang', tag);
window.history.replaceState(null, '', url);
});
return select;
}

window.DeckI18n = {
apply: apply, safeFragment: safeFragment, langFromUrl: langFromUrl, fitSlide: fitSlide, autoFit: autoFit, refit: refit, mountPicker: mountPicker,
};
})();
Loading
Loading