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 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 = Markly.parse(raw, flags: Markly::UNSAFE | Markly::FRONT_MATTER | Markly::INLINE_CODE_INFO | Markly::HTML_BLOCK_BLANK_LINES, extensions: Fragment::EXTENSIONS)
	
	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
		
		content = parse_sections(document)
		notes = Fragment.new(notes_node)
	else
		content = parse_sections(document)
		notes = nil
	end
	
	Slide.new(presentation, path, front_matter: front_matter, content: content, 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 = Markly.parse(included_raw, flags: Markly::UNSAFE | Markly::FRONT_MATTER | Markly::INLINE_CODE_INFO | Markly::HTML_BLOCK_BLANK_LINES, extensions: Fragment::EXTENSIONS)
		
		# 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)
	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

def parse_sections(document)

Parse a Markly document into content sections based on top-level headings.

Each heading becomes a named key; content before the first heading is collected under "body". Each value is a class Presently::Slide::Fragment wrapping a document node.

Signature

parameter document Markly::Node

The document to parse.

returns Hash(String, Fragment)

Sections keyed by heading name.

Implementation

def parse_sections(document)
	sections = {}
	current_key = "body"
	current_node = Markly::Node.new(:document)
	
	document.each do |node|
		if node.type == :header
			sections[current_key] = Fragment.new(current_node) unless current_node.first_child.nil?
			current_key = node.to_plaintext.strip.downcase.gsub(/\s+/, "_")
			current_node = Markly::Node.new(:document)
		else
			current_node.append_child(node.dup)
		end
	end
	
	sections[current_key] = Fragment.new(current_node) unless current_node.first_child.nil?
	
	sections
end