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
37 changes: 37 additions & 0 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,43 @@ Each slide has three parts:
2. **Content** with Markdown headings that become named sections for the template.
3. **Presenter notes** after a `---` separator in the body (optional).

### Styling Slides

CSS can be colocated with the slides it styles:

``` text
slides/
├── style.css
├── 010-introduction.md
└── 020-scheduling/
├── style.css
├── 010-overview.md
├── 020-queue.md
└── 020-queue.css
```

`slides/style.css` applies globally. A nested `style.css` applies to every slide below that directory, while a CSS file matching a Markdown filename applies only to that slide. In the example above, `020-scheduling/style.css` applies to both scheduling slides and `020-queue.css` applies only to `020-queue.md`.

Presently automatically wraps nested and slide-specific stylesheets in an [`@scope`](https://developer.mozilla.org/en-US/docs/Web/CSS/@scope) rule rooted at the rendered slide. Write these files as scoped CSS fragments containing ordinary style rules and nestable grouping rules such as `@media`, `@supports`, `@container`, and `@layer`. Keep stylesheet-level rules such as `@charset`, `@import`, and `@namespace`, and globally named definitions such as `@font-face`, `@keyframes`, and `@property`, in the root `slides/style.css` or the existing `public/_static/custom.css`.

Relative images and fonts remain adjacent to the stylesheet that uses them:

``` css
.architecture {
background-image: url("architecture.svg");
}
```

Presently uses `protocol-media-registry` to determine asset content types. Files with unrecognized media types are not served.

Relative images embedded in Markdown resolve from the Markdown file's directory, including images in included Markdown files:

``` markdown
![Architecture](architecture.svg)
```

Presently loads each discovered stylesheet once in deterministic presentation order. Directory styles are loaded from parent to child before the matching slide sidecar, so more specific styles naturally appear later in the cascade. The same stylesheets are used by the display, presenter, recorder, playback, and export interfaces.

### Running the Presentation

Start the server from your presentation directory:
Expand Down
1 change: 1 addition & 0 deletions lib/presently.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative "presently/version"

require_relative "presently/recordings"
require_relative "presently/stylesheet"
require_relative "presently/recordings/normalizer"
require_relative "presently/recording_view"
require_relative "presently/playback"
Expand Down
27 changes: 16 additions & 11 deletions lib/presently/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_relative "presenter_view"
require_relative "recording_view"
require_relative "recordings"
require_relative "slide_assets"
require_relative "playback"
require_relative "export"
require_relative "page"
Expand All @@ -34,7 +35,8 @@ def initialize(delegate, slides_root: "slides", templates_roots: [], recordings_
@recordings = Recordings.new(recordings_root || File.expand_path("../audio", slides_root))
@playback_recordings = Recordings.new(playback_recordings_root || File.expand_path("../audio-normalized", slides_root))

super(delegate)
slide_assets = SlideAssets.new(delegate, root: @slides_root, stylesheets: ->{controller.presentation.stylesheets})
super(slide_assets)
end

# The view classes that this application allows.
Expand Down Expand Up @@ -66,12 +68,17 @@ def title
"Presently"
end

# Create the presentation display page for the root route.
# @returns [Page] The presentation page.
def index
page(body)
end

# Add Presently's routes to Lively's standard application routes.
# @parameter router [Lively::Router] The router to configure.
def configure_routes(router)
super

router.get("/"){render_page(DisplayView.new(controller: controller))}
router.get("/presenter"){render_page(PresenterView.new(controller: controller))}
router.get("/record"){render_page(RecordingView.new(controller: controller))}

Expand All @@ -92,19 +99,17 @@ def configure_routes(router)
end
end

# Delegate requests which do not match a configured route.
# @parameter request [Protocol::HTTP::Request] The incoming request.
# @returns [Protocol::HTTP::Response] The delegate response.
def handle(request)
delegate.call(request)
end

private

# Create a Presently page with the presentation-specific stylesheets.
def page(body)
stylesheets = controller.presentation.stylesheets.map(&:url)
Page.new(title: title, body: body, stylesheets: stylesheets)
end

# Render one of Presently's live interfaces.
def render_page(body)
page = Page.new(title: title, body: body)
Protocol::HTTP::Response[200, [], [page.call]]
Protocol::HTTP::Response[200, [], [page(body).call]]
end

# Render the narrated playback interface.
Expand Down
5 changes: 5 additions & 0 deletions lib/presently/export.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ def initialize(presentation:, page_size: PageSize::DEFAULT, notes: true, speaker
# @attribute [Boolean] Whether slide timing is included.
attr :timing

# @returns [Array(Stylesheet)] The ordered presentation stylesheets.
def stylesheets
@presentation.stylesheets
end

# Render a single slide to an HTML string.
# @parameter slide [Slide] The slide to render.
# @returns [XRB::MarkupString]
Expand Down
3 changes: 3 additions & 0 deletions lib/presently/export.xrb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<link rel="stylesheet" href="/_static/index.css" type="text/css" />
<link rel="stylesheet" href="/_static/custom.css" type="text/css" />
<link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" />
<?r self.stylesheets.each do |stylesheet| ?>
<link rel="stylesheet" href="#{stylesheet.url}" type="text/css" />
<?r end ?>

<script type="importmap">
{
Expand Down
5 changes: 3 additions & 2 deletions lib/presently/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,13 @@ class Page < Lively::Page
# Initialize a new page.
# @parameter title [String] The page title.
# @parameter body [Live::View | Nil] The Live view to embed in the page.
def initialize(title: "Presently", body: nil)
# @parameter stylesheets [Array(String | Hash)] Presentation-specific stylesheets.
def initialize(title: "Presently", body: nil, stylesheets: [])
super(
title: title,
body: body,
icon: ICON,
stylesheets: STYLESHEETS,
stylesheets: STYLESHEETS + stylesheets,
imports: IMPORTS,
modules: MODULES,
)
Expand Down
5 changes: 5 additions & 0 deletions lib/presently/playback.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ def initialize(presentation:, recording_urls:, autoplay: false, controls: true)
attr :autoplay
attr :controls

# @returns [Array(Stylesheet)] The ordered presentation stylesheets.
def stylesheets
@presentation.stylesheets
end

# @returns [Array(Slide)] The slides in playback order.
def slides
@presentation.slides
Expand Down
3 changes: 3 additions & 0 deletions lib/presently/playback.xrb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
<link rel="stylesheet" href="/_static/index.css" type="text/css" media="screen" />
<link rel="stylesheet" href="/_static/custom.css" type="text/css" media="screen" />
<link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" media="screen" />
<?r self.stylesheets.each do |stylesheet| ?>
<link rel="stylesheet" href="#{stylesheet.url}" type="text/css" media="screen" />
<?r end ?>

<script type="importmap">
{
Expand Down
5 changes: 5 additions & 0 deletions lib/presently/presentation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Copyright, 2026, by Samuel Williams.

require_relative "slide"
require_relative "stylesheet"
require_relative "templates"

module Presently
Expand All @@ -28,6 +29,7 @@ def initialize(root = "slides", templates = Templates.for)
@root = File.expand_path(root)
@templates = templates
@slides = load_slides
@stylesheets = Stylesheet.discover(@root, @slides)
end

# @attribute [String] The absolute root directory containing slide files.
Expand All @@ -36,6 +38,9 @@ def initialize(root = "slides", templates = Templates.for)
# @attribute [Array(Slide)] The ordered list of slides.
attr :slides

# @attribute [Array(Stylesheet)] The ordered presentation stylesheets.
attr :stylesheets

# @attribute [Templates] The template resolver.
attr :templates

Expand Down
38 changes: 35 additions & 3 deletions lib/presently/slide.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@

require "yaml"
require "markly"
require "protocol/url"

require "markly/renderer/html"

require_relative "stylesheet"

module Presently
# A single slide parsed from a Markdown file.
#
Expand Down Expand Up @@ -71,7 +74,8 @@ def load(presentation, path)
# Parse once, with native front matter support.
document = Markly.parse(raw, flags: Markly::UNSAFE | Markly::FRONT_MATTER, extensions: Fragment::EXTENSIONS)

expand_includes!(document, File.dirname(source_path))
expand_includes!(document, File.dirname(source_path), presentation.root)
rewrite_image_urls!(document, source_path, presentation.root)

# Extract front matter from the first AST node if present.
front_matter = nil
Expand Down Expand Up @@ -125,8 +129,9 @@ def load(presentation, path)
#
# @parameter document [Markly::Node] The document to expand in-place.
# @parameter base_dir [String] Directory used to resolve relative paths.
# @parameter root [String] The presentation asset root.
# @parameter depth [Integer] Current recursion depth (guards against cycles).
def expand_includes!(document, base_dir, depth: 0)
def expand_includes!(document, base_dir, root, depth: 0)
raise "Include depth limit exceeded" if depth > 10

# Collect matching paragraphs first — mutating the tree while iterating is unsafe.
Expand All @@ -150,13 +155,40 @@ def expand_includes!(document, base_dir, depth: 0)
front_matter_node.delete
end

expand_includes!(included_document, File.dirname(included_path), depth: depth + 1)
expand_includes!(included_document, File.dirname(included_path), root, depth: depth + 1)
rewrite_image_urls!(included_document, included_path, root)

included_document.each{|node| paragraph.insert_before(node.dup)}
paragraph.delete
end
end

# Rewrite relative Markdown image destinations so they remain relative to
# the source file after its content is rendered into a shared HTML page.
# @parameter document [Markly::Node] The document containing image nodes.
# @parameter source_path [String] The Markdown source path.
# @parameter root [String] The presentation asset root.
def rewrite_image_urls!(document, source_path, root)
directory = File.dirname(File.expand_path(source_path))
root = File.expand_path(root)
return unless directory == root || directory.start_with?(root + File::SEPARATOR)

relative_directory = directory.delete_prefix(root).delete_prefix(File::SEPARATOR)
base_path = Stylesheet::PREFIX
base_path += Stylesheet.encode_path(relative_directory) + "/" unless relative_directory.empty?
base_url = Protocol::URL::Relative.new(base_path)

document.walk do |node|
next unless node.type == :image

url = Protocol::URL[node.url]
next unless url.is_a?(Protocol::URL::Relative)
next if url.path.empty? || url.path.absolute?

node.url = (base_url + url).to_s
end
end

# Parse a Markly document into content sections based on top-level headings.
#
# Each heading becomes a named key; content before the first heading is
Expand Down
101 changes: 101 additions & 0 deletions lib/presently/slide_assets.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "protocol/http/body/file"
require "protocol/http/middleware"
require "protocol/media/registry"
require "protocol/url"

require_relative "stylesheet"

module Presently
# Serves scoped presentation stylesheets and their adjacent assets.
class SlideAssets < Protocol::HTTP::Middleware
# @parameter delegate [Protocol::HTTP::Middleware] The next middleware.
# @parameter root [String] The presentation slides root.
# @parameter stylesheets [Proc] Returns the currently discovered stylesheets.
def initialize(delegate, root:, stylesheets:)
super(delegate)

@root = File.realpath(root)
@stylesheets = stylesheets
end

# Serve a presentation stylesheet or adjacent asset.
def call(request)
path = request_path(request)
return super unless path&.start_with?(Stylesheet::PREFIX)

unless request.method == "GET" || request.method == "HEAD"
return Protocol::HTTP::Response[405, [["allow", "GET, HEAD"]]]
end

relative_path = decode_path(path.delete_prefix(Stylesheet::PREFIX))
return Protocol::HTTP::Response[404] unless relative_path

if File.extname(relative_path) == ".css"
serve_stylesheet(request, relative_path)
else
serve_asset(request, relative_path)
end
end

private

def request_path(request)
Protocol::URL::Reference[request.path]&.path&.to_s
rescue ArgumentError
nil
end

def decode_path(path)
components = Protocol::URL::Path[path].components(Protocol::URL::Encoding::System)
return if components.empty? || components.any?{|component| component.empty? || component == "." || component == ".."}

components.join(File::SEPARATOR)
rescue ArgumentError
nil
end

def serve_stylesheet(request, relative_path)
stylesheet = @stylesheets.call.find{|stylesheet| stylesheet.path == relative_path}
return Protocol::HTTP::Response[404] unless stylesheet

headers = asset_headers(media_type_for(relative_path))
body = [stylesheet.read] unless request.method == "HEAD"
Protocol::HTTP::Response[200, headers, body]
end

def serve_asset(request, relative_path)
media_type = media_type_for(relative_path)
return Protocol::HTTP::Response[404] unless media_type

path = resolve_path(relative_path)
return Protocol::HTTP::Response[404] unless path

body = Protocol::HTTP::Body::File.open(path) unless request.method == "HEAD"
Protocol::HTTP::Response[200, asset_headers(media_type), body]
end

def media_type_for(path)
Protocol::Media::Registry.for_path(path)&.type&.to_s
end

def resolve_path(relative_path)
path = File.realpath(File.join(@root, relative_path))
prefix = @root.end_with?(File::SEPARATOR) ? @root : @root + File::SEPARATOR
path if path.start_with?(prefix) && File.file?(path)
rescue Errno::ENOENT
nil
end

def asset_headers(content_type)
[
["content-type", content_type],
["cache-control", "no-store, no-cache, must-revalidate, max-age=0"],
]
end
end
end
3 changes: 2 additions & 1 deletion lib/presently/slide_renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require "xrb/markup"

require_relative "templates"
require_relative "stylesheet"

module Presently
# Renders a single slide using its XRB template.
Expand Down Expand Up @@ -45,7 +46,7 @@ def render(builder, slide, extra_class: nil)
html = template.to_string(scope)

classes = [@css_class, extra_class].compact.join(" ")
builder.tag(:div, class: classes, data: {template: slide.template}) do
builder.tag(:div, class: classes, data: {template: slide.template}, "data-slide-path": Stylesheet.encode_path(slide.path)) do
builder.raw(html)
if slide.script
builder.tag(:script, type: "text/slide-script") do
Expand Down
Loading
Loading