module Parser

Parses a Markdown slide file into structured data for class Presently::Slide.

Handles YAML front_matter extraction, presenter note separation, and Markdown AST construction via Markly.

Definitions

def parse(source)

Parse slide Markdown with the standard flags and extensions.

Signature

parameter source String

The Markdown source.

returns Markly::Node

The parsed document.

Implementation

def parse(source)
	Markly.parse(source, flags: FLAGS, extensions: Fragment::EXTENSIONS)
end

def load(presentation, path)

Parse the file and return a class Presently::Slide.

Signature

parameter presentation Presentation

The presentation which owns the slide.

parameter path String

The slide path relative to the presentation root.

returns Slide

Implementation

def load(presentation, path)
	source_path = File.join(presentation.root, path)
	raw = File.read(source_path)
	
	# Parse once, with native front matter support.
	document = parse(raw)
	
	expand_includes!(document, File.dirname(source_path), presentation.root)
	rewrite_image_urls!(document, source_path, presentation.root)
	scripts = extract_setup_scripts!(document)
	
	# Extract front matter from the first AST node if present.
	front_matter = nil
	if (front_matter_node = document.first_child) && front_matter_node.type == :front_matter
		front_matter = YAML.safe_load(front_matter_node.string_content)
		front_matter_node.delete
	end
	
	# Find the last hrule, which acts as the separator between slide content and presenter notes.
	last_hrule = nil
	document.each{|node| last_hrule = node if node.type == :hrule}
	
	if last_hrule
		notes_node = Markly::Node.new(:document)
		while child = last_hrule.next
			notes_node.append_child(child)
		end
		last_hrule.delete
		
		# Extract the last javascript code block from the notes as the slide script.
		script_node = nil
		notes_node.each do |node|
			if node.type == :code_block && node.fence_info.to_s.strip == "javascript"
				script_node = node
			end
		end
		
		if script_node
			scripts << script_node.string_content
			script_node.delete
		end
		
		notes = Fragment.new(notes_node)
	else
		notes = nil
	end
	
	Slide.new(presentation, path, front_matter: front_matter, document: Fragment.new(document), notes: notes, scripts: scripts)
end

def extract_setup_scripts!(document)

Extract reusable setup scripts from the expanded slide document.

Setup scripts use a javascript presently fenced code block. They are removed from rendered Markdown and executed in document order before the slide-specific script from the presenter notes.

Signature

parameter document Markly::Node

The expanded slide document.

returns Array(String)

The extracted JavaScript sources.

Implementation

def extract_setup_scripts!(document)
	script_nodes = []
	
	document.each do |node|
		if node.type == :code_block && node.fence_info.to_s.strip == "javascript presently"
			script_nodes << node
		end
	end
	
	script_nodes.map do |node|
		script = node.string_content
		node.delete
		script
	end
end

def expand_includes!(document, base_dir, root, depth: 0)

Expand ![[path/to/file.md]] include directives in a parsed document.

Scans top-level paragraph nodes for the Obsidian-style embed syntax and replaces each one with the parsed AST of the referenced file. Includes are resolved relative to base_dir. Front matter in included files is stripped. Nested includes are expanded recursively up to a depth of 10.

Signature

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).

Implementation

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.
	to_replace = []
	document.each do |node|
		next unless node.type == :paragraph
		child = node.first_child
		next unless child && child.next.nil? && child.type == :text
		next unless child.string_content =~ /\A!\[\[(.+?)\]\]\z/
		to_replace << [node, $1.strip]
	end
	
	to_replace.each do |paragraph, relative_path|
		included_path = File.expand_path(relative_path, base_dir)
		included_raw = File.read(included_path)
		included_document = parse(included_raw)
		
		# Strip front matter from included file if present.
		front_matter_node = included_document.first_child
		if front_matter_node&.type == :front_matter
			front_matter_node.delete
		end
		
		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

def rewrite_image_urls!(document, source_path, root)

Rewrite relative Markdown image destinations so they remain relative to the source file after its content is rendered into a shared HTML page.

Signature

parameter document Markly::Node

The document containing image nodes.

parameter source_path String

The Markdown source path.

parameter root String

The presentation asset root.

Implementation

def rewrite_image_urls!(document, source_path, root)
	document.walk do |node|
		node.url = resolve_image_url(node.url, source_path, root) if node.type == :image
	end
end

def resolve_image_url(url, source_path, root)

Resolve an image URL relative to its source file within the presentation.

Signature

parameter url String

The image URL.

parameter source_path String

The source file path.

parameter root String

The presentation asset root.

returns String

The resolved URL, preserving absolute URLs.

Implementation

def resolve_image_url(url, source_path, root)
	reference = Protocol::URL[url]
	return url unless reference.is_a?(Protocol::URL::Relative)
	return url if reference.path.empty? || reference.path.absolute?
	
	directory = File.dirname(File.expand_path(source_path))
	root = File.expand_path(root)
	return url 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)
	
	(base_url + reference).to_s
end