#!/usr/bin/env ys-0 !ys-0 use: ys::fs: :as fs ys::yaml: :as yaml BLOCK-RE =: /(?m)^----[ \t]*/ URL-RE =: /https?:\/\/[^\s<]+/ BOLD-SPANS =: /``[^\r\n]*?``|\^\^[^\r\n]*?\^\^|%%[^\r\n]*?%%/ SINGLE-SPANS =: /`[^`\r\n]*`|\^[^^\r\n]*\^|\#[^\#\r\n]*\#|%[^%\r\n]*%/ STAR-SPAN =: /\*[^*\s\r\n](?:[^*\r\n]*[^*\s\r\n])?\*/ YELLOW-SPAN =: /@[^@\r\n]*@/ SPAN-RE =: qr("$BOLD-SPANS|$SINGLE-SPANS|$STAR-SPAN|$YELLOW-SPAN") TEXT-RE =: qr("(.*?)($SPAN-RE)|(.*)") CENTER-MARK =: "\uE000" DEFAULT-CONFIG =:: title: Vroom Slides indent: 0 height: 24 width: 80 before: 1 defn main(input='slides.vroom', output='slides/index.html'): source =: read(input).replace(/\r\n?/ "\n") config slides count =: compile-deck(source) directory =: fs/dirname(output) fs/mkdir-p: directory temporary =: "$output.tmp" write temporary: page-html(config slides count) fs/rm-f: output fs/mv: temporary output write "$directory/.nojekyll": '' say: "Generated $output" defn compile-deck(source): blocks =: source.split(BLOCK-RE).drop(1) when blocks.!: die: 'No Vroom slides found' loop blocks blocks, config DEFAULT-CONFIG, slides [], number 0: if blocks.!: then: :[config slides number] else: block =: blocks.0 =~ /(?s)^([^\n]*)\n(.*)\z/ when block.!: die: "Malformed Vroom slide block: $(pr-str(blocks.0))" options =: trim(block.1) body =: block.2 if options == 'config': then: recur: blocks:rest merge(config yaml/load(body)) slides number else: flags =: parse-options(options) if flags.skip: then: recur(blocks:rest, config, slides, number) else: number =: number.++ states =: slide-states(body config flags number) recur: blocks:rest config into(slides states) number defn parse-options(text): options =: if text.!: + [] text.split(/\s*,\s*/) allowed =: set(qw(center replace skip)) unknown =: options.remove(\(allowed-option?(_ allowed))) when unknown.?: die: "Unsupported Vroom option: $(unknown:first)" =>:: center:: some(\(_ == 'center') options).?? replace:: some(\(_ == 'replace') options).?? skip:: some(\(_ == 'skip') options).?? indent:: option-number(options /^i(\d+)$/) undent:: option-number(options /^i-(\d+)$/) defn allowed-option?(option allowed): allowed(option) || (option =~ /^i-?\d+$/) defn option-number(options pattern): some(\(matched-number(_ pattern)) options) defn matched-number(option pattern): match =: option =~ pattern when match.?: N(match.1) defn slide-states(body config flags number): clean-body =: strip-notes(body) formatted =: format-slide(clean-body config flags) parts =: formatted.split(/(?m)^\+/) states =: if flags.replace: then: parts else: reductions(append-text parts) steps =: states.# mapper =: partial(make-state number steps config flags) result =: states.map-indexed(mapper _) if steps > 1: then: mark-current-bullets(result flags.replace) else: result defn append-text(left right): =>: "$left$right" defn make-state(number steps config flags step state): =>:: number:: number step:: step steps:: steps content:: finish-slide(state config flags) defn mark-current-bullets(states replace): loop remaining states, previous [], active [], result []: if remaining.!: then: result else: state =: remaining:first rows =: lines(state.content) append =: replace.! && (rows.take(previous.#) == previous) start =: if(append, previous.#, 0) items =: bullet-items(rows) fresh =: items.filter(\(some(ge(start) _))) current =: if fresh.?: then: fresh.mapcat(identity) else: if(append, active, []) final =: if state.step == state.steps.--: then: items:last || [] else: :[] marked =: state.put('current' set(current), 'final' set(final)) recur: remaining:rest, rows, current, result.conj(marked) defn bullet-items(rows): loop row 0, parents [], items []: if row >= rows.#: then: items else: plain =: rows.$row.replace(SPAN-RE span-body) .replace(CENTER-MARK '') if trim(plain).!: then: recur(row.++ parents items) else: indent =: re-find(/^[ \t]*/ plain).# parents =: parents.take-while(\(_.0 < indent)):V cond: plain =~ /^[ \t]*\*\s+/: recur: row.++ parents.conj([indent items.#]) items.conj([row]) parents.?: index =: parents:last.1 recur: row.++, parents, items.put(index items.$index.conj(row)) else: recur(row.++ parents items) defn span-body(piece): bold =: piece.starts?('``') || piece.starts?('^^') || piece.starts?('%%') width =: if(bold, 2, 1) end =: piece.# - width if piece.# <= (2 * width): then: piece else: subs(piece width end) defn strip-notes(body): clean =: body.replace(/(?ms)^====\n.*\z/ '') .replace(/(?m)^%[^\n]*\n/ keep-percent-span) =>: clean defn keep-percent-span(line): if line =~ /^%[^%\r\n]*%/: then: line else: '' defn format-slide(body config flags): indent =: if flags.indent == nil: then: N(config.indent || 0) else: flags.indent formatted-lines =: lines(body).map(\(format-line(_, config, flags, indent))) formatted =: formatted-lines.join("\n") =>: formatted defn format-line(line config flags indent): transition =: line.starts?('+') text =: if(transition.??, subs(line 1), line) prefix =: if(transition.??, '+', '') if flags.center: then: trimmed =: trim(text) if trimmed.!: then: prefix else: "$prefix$CENTER-MARK$trimmed" else: "$prefix$(spaces(indent))$text" defn finish-slide(text config flags): trimmed =: trim-slide(text) finished =: if flags.undent == nil: then: trimmed else: trimmed.replace( re-pattern("(?m)^.{$(flags.undent)}") '') before =: N(config.before || 1) newline =: "\n" padding =: newline.repeat(before).join('') =>: "$padding$finished" defn trim-slide(text): clean =: text.replace(/\A\s*\n/ '') .replace(/\n\s*\z/ '') .replace(/(?m)[ ]+$/ '') =>: clean defn spaces(count): space =: ' ' result =: space.repeat(count).join('') =>: result defn page-html(config slides count): sections =: slides.map(render-slide).join("\n") title =: html-escape(str(config.title)) width =: N(config.width || 80) height =: N(config.height || 24) =>: |
$escaped
defn render-comment(text):
rendered =: html-escape(text).replace(URL-RE link-url)
=>: |-
$rendered
defn render-yellow(text):
rendered =: html-escape(text).replace(URL-RE link-url)
=>: |-
$rendered
defn render-accent(text bold):
classes =: if(bold, 'accent-span bold', 'accent-span')
rendered =: html-escape(text).replace(URL-RE link-url)
=>: |-
$rendered
defn render-emphasis(text bold):
classes =: if(bold, 'emphasis bold', 'emphasis')
rendered =: html-escape(text).replace(URL-RE link-url)
=>: |-
$rendered
defn link-url(url):
=>: |-
$url
defn html-escape(text):
escaped =: text.replace('&' '&')
.replace('<' '<')
.replace('>' '>')
.replace('"' '"')
=>: escaped
defn page-css(width height):
=>: |
:root {
color-scheme: dark;
--columns: $width;
--rows: $height;
--background: #300a24;
--foreground: #f7f7f7;
--command: #d7a900;
--link: #35c9ff;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; }
body {
overflow: hidden;
background: var(--background);
color: var(--foreground);
font-family: "Ubuntu Mono", "DejaVu Sans Mono", monospace;
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
}
main { width: 100%; height: 100%; }
.slide {
width: 100%;
height: 100%;
display: grid;
place-items: center;
cursor: pointer;
touch-action: manipulation;
}
.slide[hidden] { display: none; }
pre {
width: calc(var(--columns) * 1ch);
height: calc(var(--rows) * 1.2em);
margin: 0;
font: inherit;
font-size: min(calc(94vw / var(--columns) * 1.65),
calc(76vh / var(--rows)));
line-height: 1.2;
white-space: pre;
}
.line {
display: block;
min-height: 1.2em;
padding-left: calc(var(--indent) * 1ch);
}
.command { color: var(--command); }
.current-bullet { color: #ffffff; font-weight: bold; }
.last-bullet { text-decoration: underline; }
.heading { color: #ffafff; }
.emphasis, .emphasis a { color: #87ffaf; font-weight: normal; }
.code-span { color: #afd7ff; font: inherit; font-weight: normal; }
.comment-span, .comment-span a { color: #bcbcbc; font-weight: normal; }
.accent-span, .accent-span a { color: #ffaf87; font-weight: normal; }
.yellow-span, .yellow-span a {
color: #ffffaf; font-weight: normal;
}
.bold, .bold a { font-weight: bold; }
.heading, .centered {
padding-left: 0;
text-align: center;
}
a { color: var(--link); text-decoration: none; }
a:hover, a:focus { text-decoration: underline; }
#counter {
position: fixed;
right: 1rem;
bottom: .6rem;
color: #bdaebb;
font: 14px "Ubuntu Mono", monospace;
}
#navigation {
position: fixed;
left: 50%;
bottom: .6rem;
transform: translateX(-50%);
color: #bdaebb;
font: 14px "Ubuntu Mono", monospace;
white-space: nowrap;
}
#previous {
display: none;
min-width: 44px;
min-height: 44px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
opacity: .8;
}
#previous:disabled { cursor: default; opacity: .25; }
#previous:not(:disabled):hover,
#previous:not(:disabled):focus { opacity: 1; }
#repository {
position: fixed;
left: 1rem;
bottom: .55rem;
width: 18px;
height: 18px;
color: #bdaebb;
opacity: .55;
}
#repository svg {
display: block;
width: 100%;
height: 100%;
fill: currentColor;
}
#repository:hover, #repository:focus {
opacity: 1;
text-decoration: none;
}
.compact-navigation {
display: none;
position: relative;
}
@media (max-width: 600px),
(hover: none) and (pointer: coarse) and (max-width: 1000px) {
.slide { padding-bottom: 3rem; }
#navigation {
left: 1rem;
transform: none;
font-size: 12px;
}
#counter { font-size: 12px; }
#repository, .full-navigation { display: none; }
#previous {
display: inline-flex;
position: absolute;
left: calc(100% + .5rem);
bottom: -.6rem;
align-items: flex-end;
padding-bottom: .6rem;
box-sizing: border-box;
}
.compact-navigation {
display: inline-block;
}
}
@media (max-width: 600px) and (orientation: portrait) {
.slide { padding: 1rem 0 3rem; }
pre {
width: 92vw;
height: auto;
max-height: calc(100vh - 5rem);
overflow-y: auto;
overflow-wrap: anywhere;
font-size: clamp(12px, 3vw, 15px);
white-space: pre-wrap;
}
}
@media print {
body { overflow: visible; }
.slide { break-after: page; height: 100vh; }
.slide[hidden] { display: grid; }
#counter, #navigation, #repository, #previous { display: none; }
}
defn page-js(count):
=>: |
"use strict";
const frames = [...document.querySelectorAll(".slide")];
const counter = document.querySelector("#counter");
const previous = document.querySelector("#previous");
let index = 0;
function hashFor(frame) {
return "#" + frame.dataset.slide + "." +
(Number(frame.dataset.step) + 1);
}
function indexFromHash() {
const match = location.hash.match(/^#(\d+)\.(\d+)$/);
if (!match) return 0;
const found = frames.findIndex(frame =>
frame.dataset.slide === match[1] &&
Number(frame.dataset.step) + 1 === Number(match[2]));
return found < 0 ? 0 : found;
}
function show(next, updateHash = true) {
index = Math.max(0, Math.min(frames.length + -1, next));
frames.forEach((frame, position) => {
frame.hidden = position !== index;
});
const frame = frames[index];
counter.value = frame.dataset.slide + " / $count";
previous.disabled = index === 0;
if (updateHash && location.hash !== hashFor(frame)) {
history.replaceState(null, "", hashFor(frame));
}
}
function move(amount) { show(index + amount); }
addEventListener("keydown", event => {
const forward = [" ", "Enter", "ArrowRight", "ArrowDown",
"PageDown"];
const backward = ["Backspace", "ArrowLeft", "ArrowUp", "PageUp"];
if (forward.includes(event.key)) {
event["preventDefault"]();
move(1);
} else if (backward.includes(event.key)) {
event["preventDefault"]();
move(-1);
} else if (event.key === "Home") {
event["preventDefault"]();
show(0);
} else if (event.key === "End") {
event["preventDefault"]();
show(frames.length + -1);
}
});
const tapEvent = "PointerEvent" in window ? "pointerup" : "click";
frames.forEach(frame => frame.addEventListener(tapEvent, event => {
if (event.target.closest("a")) return;
if ("button" in event && event.button !== 0) return;
move(1);
}));
previous.addEventListener("click", event => {
event["preventDefault"]();
event["stopPropagation"]();
move(-1);
});
addEventListener("hashchange", () => show(indexFromHash(), false));
show(indexFromHash());