#!/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) =>: | $title
$sections
defn indent-text(text count): indented =: text.replace(/(?m)^/ spaces(count)) =>: indented defn render-slide(slide): renderer =: partial(render-line slide) content =: lines(slide.content).map-indexed(renderer _).join('') number =: slide.number step =: slide.step steps =: slide.steps =>: | defn render-line(slide row line): centered =: line.starts?(CENTER-MARK) raw =: if(centered.??, subs(line 1), line) match =: raw =~ /^([ ]*)(.*)$/ indent =: match.1.# unindented =: match.2 heading =: unindented.starts?('== ') visible =: if heading: then: unindented.replace(/^==\s+/ '') else: unindented kind =: cond: heading: 'heading' centered: 'centered' trim(visible) =~ /^[\$>]\s/: 'command' true: '' classes =: if(kind.!, 'line', "line $kind") classes =: if contains?(slide.current row): then: "$classes current-bullet" else: classes classes =: if contains?(slide.final row) && contains?(slide.current row): then: "$classes last-bullet" else: classes text =: render-text(visible) =>: |- $text defn render-text(text): groups =: re-seq(TEXT-RE text) groups: .map(render-group).join('') defn render-group(group): prefix =: group.1 || group.3 || '' plain =: html-escape(prefix).replace(URL-RE link-url) styled =: if group.2.?: then: render-piece(group.2) else: '' plain +: styled defn render-piece(piece): bold =: piece.starts?('``') || piece.starts?('^^') || piece.starts?('%%') width =: if(bold, 2, 1) if piece.# <= (2 * width): then: html-escape(piece) else: end =: piece.# - width text =: subs(piece width end) cond: piece.starts?('*'): render-bold(text) piece.starts?('`'): render-code(text bold) piece.starts?('#'): render-comment(text) piece.starts?('%'): render-accent(text bold) piece.starts?('@'): render-yellow(text) else: render-emphasis(text bold) defn render-bold(text): rendered =: html-escape(text).replace(URL-RE link-url) =>: |- $rendered defn render-code(text bold): classes =: if(bold, 'code-span bold', 'code-span') escaped =: html-escape(text) =>: |- $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());