Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Even more CSV support in Jekyll #107

Merged
merged 8 commits into from
Nov 3, 2024
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
5 changes: 4 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ source "https://rubygems.org"

gem "jekyll", "~> 4.3.4"
gem "posix-spawn", "~> 0.3.9"
gem "ruby-graphviz"

group :jekyll_plugins do
gem "jekyll-mermaid", "~> 1.0.0"
gem "jekyll-postfiles", "~> 3.1"
gem "jekyll-seo-tag", :git => 'https://github.com/numist/jekyll-seo-tag.git', :branch => 'issue/461'

# Local plugin dependencies
gem "ruby-graphviz"
gem "dentaku"
end

group :development do
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ GEM
bigdecimal (3.1.8)
colorator (1.1.0)
concurrent-ruby (1.3.4)
dentaku (3.5.4)
bigdecimal
concurrent-ruby
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
Expand Down Expand Up @@ -105,6 +108,7 @@ PLATFORMS
x86_64-linux

DEPENDENCIES
dentaku
jekyll (~> 4.3.4)
jekyll-mermaid (~> 1.0.0)
jekyll-postfiles (~> 3.1)
Expand Down
203 changes: 146 additions & 57 deletions _plugins/csv_block.rb
Original file line number Diff line number Diff line change
@@ -1,77 +1,166 @@
# _plugins/csv_block.rb
require 'jekyll'
require 'liquid'
require 'csv'

# Usage:
#
# {% csv %}
# Name, Age, Occupation
# Alice, 30, Engineer
# Bob, 25, Designer
# Charlie, 35, Teacher
# {% endcsv %}
#
# {% csv header:false %}
# Alice, 30, Engineer
# Bob, 25, Designer
# Charlie, 35, Teacher
# {% endcsv %}
#
# {% csv header:true separator:tab %}
# Name Age Occupation
# Alice 30 Engineer
# Bob 25 Designer
# Charlie 35 Teacher
# {% endcsv %}
#
require 'dentaku'

module Jekyll
class CSVBlock < Liquid::Block
class CSVBlock < ::Liquid::Block
def initialize(tag_name, markup, tokens)
super
# Parse options from the markup (e.g., header: false, separator: tab)
@options = {}
markup.strip.split.each do |option|
key, value = option.split(':')
if key == 'header'
@options[key] = value == 'false' ? false : true
elsif key == 'separator'
@options[key] = value == 'tab' ? "\t" : ','
end
@options[key] = (value == 'false' ? false : true) if key == 'header'
@options[key] = (value == 'tab' ? "\t" : ',') if key == 'separator'
end
@calculator = Dentaku::Calculator.new
@evaluation_cache = {}
end

def render(context)
csv_text = super.strip
has_headers = @options.fetch('header', true)
separator = @options.fetch('separator', ',')

# Parse CSV with or without headers and with the specified separator
csv_data = CSV.parse(csv_text, headers: has_headers, col_sep: separator)

# Start generating HTML
html = "<table>\n"

if has_headers
# Generate header row
html << "<thead>\n<tr>\n"
csv_data.headers.each do |header|
html << "<th>#{header}</th>"
begin

csv_text = super.strip
has_headers = @options.fetch('header', true)
separator = @options.fetch('separator', ',')

csv_data = CSV.parse(csv_text, headers: has_headers, col_sep: separator)
if has_headers
table_data = [csv_data.headers] + csv_data.map(&:to_hash).map(&:values)
else
table_data = csv_data
end
html << "</tr>\n</thead>\n"

html = "<table>\n"

if has_headers
html << "<thead>\n<tr>\n"
table_data.first.each_with_index do |header, col_index|
html << "<th>#{evaluate_formula_at_index(col_index, 0, table_data)}</th>"
end
html << "</tr>\n</thead>\n"
end

html << "<tbody>\n"
table_data.each_with_index do |row, row_index|
next if row_index == 0 and has_headers
html << "<tr>"
row.each_with_index do |value, col_index|
evaluated_value = evaluate_formula_at_index(col_index, row_index, table_data)
html << "<td>#{evaluated_value}</td>"
end
html << "</tr>\n"
end
html << "</tbody>\n</table>\n"

return html
rescue => e
return e.message
end
end

private

#
# Conversion functions between spreadsheet style (B3, "val") and zero-based coordinates ([2, 1], "index")
#

def col_index_to_val(col_index)
if col_index < 26
(col_index + 'A'.ord).chr
else
quotient, remainder = col_index.divmod(26)
col_index_to_val(quotient - 1) + col_index_to_val(remainder)
end
end

def col_val_to_index(col_val)
if col_val.length == 1
col_val.ord - 'A'.ord
else
base = 'Z'.ord - 'A'.ord + 1
col_val.chars.reduce(0) { |result, char| result * base + char.ord - 'A'.ord + 1 } - 1
end
end

def row_index_to_val(row_index)
row_index + 1
end

def row_val_to_index(row_val)
row_val - 1
end

# Convert zero-based coordinates to spreadsheet style
def indices_to_val(col_index, row_index)
"#{col_index_to_val(col_index)}#{row_index_to_val(row_index)}"
end

# Evaluate a formula at a given cell index
# This wrapper function catches exceptions thrown from the actual implementation, converting them into error
# messages for display in the rendered HTML. This is necessary because Liquid does not support exceptions in
# custom tags, swallowing the error details.
def evaluate_formula_at_index(col_index, row_index, table_data)
begin
evaluate_formula_at_index_internal(col_index, row_index, table_data)
rescue => e
e.message
end
end

# Implementation of formula evaluation.
def evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting = Set.new)
value = table_data[row_index][col_index].strip
return value unless value.is_a?(String) && value.start_with?('=')

cell_key = [col_index, row_index]
return @evaluation_cache[cell_key] if @evaluation_cache.key?(cell_key)

if visiting.include?(cell_key)
raise "Error: circular reference: #{visiting.to_a.map { |key| "#{col_index_to_val(key[0])}#{row_index_to_val(key[1])}" }.sort.join(', ')}"
else
visiting.add(cell_key)
end

# Generate data rows
html << "<tbody>\n"
csv_data.each do |row|
html << "<tr>"
row = row.to_hash.values if has_headers
row.each do |value|
html << "<td>#{value}</td>"
formula = value[1..-1].strip # Removes '=' from start of formula

# Identify cell references and replace with values
formula_with_values = formula.gsub(/([A-Z]+)(\d+)(:([A-Z]+)(\d+))?/i) do |match|
if $3
start_col, start_row, end_col, end_row = $1, $2.to_i, $4, $5.to_i
start_col_index = col_val_to_index(start_col)
start_row_index = row_val_to_index(start_row)
end_col_index = col_val_to_index(end_col)
end_row_index = row_val_to_index(end_row)

if start_col_index.nil? || start_row_index.nil? || end_col_index.nil? || end_row_index.nil? \
|| start_col_index > end_col_index || start_row_index > end_row_index \
|| end_col_index >= table_data.first.length || end_row_index >= table_data.length
raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} references invalid range #{match}"
end

range_values = []
(start_row_index..end_row_index).each do |row_index|
(start_col_index..end_col_index).each do |col_index|
range_values << evaluate_formula_at_index_internal(col_index, row_index, table_data, visiting)
end
end
range_values.join(',')
else
ref_row_index = row_val_to_index($2.to_i)
ref_col_index = col_val_to_index($1)
referenced_value = table_data.dig(ref_row_index, ref_col_index)
raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} references invalid cell #{match}" if referenced_value.nil?

evaluate_formula_at_index_internal(ref_col_index, ref_row_index, table_data, visiting)
end
html << "</tr>\n"
end
html << "</tbody>\n</table>\n"

html

evaluated_result = @calculator.evaluate(formula_with_values) or raise "Error: #{col_index_to_val(col_index)}#{row_index_to_val(row_index)} uses invalid formula: #{formula}"

@evaluation_cache[cell_key] = evaluated_result
evaluated_result
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The next day he had a prototype working[^brooks], and the day after that it was

The next week or two brought more features, like [support for emails bounced into feeds](https://github.com/indirect/feedyour.email/pull/85) using mail rules and [feed-specific favicons](https://github.com/indirect/feedyour.email/issues/23). It's really good.

As a side benefit, this exercise also gave me some exposure to modern web development[^tmbo], which was fun and led indirectly to [this blog](/post/1969/new-blog/).
As a side benefit, this exercise also gave me some exposure to modern web development[^tmbo], which was fun and led indirectly to this blog.

(P.S. André [wrote something about feedyour.email](https://andre.arko.net/2022/02/17/feedyouremail/) too)

Expand Down
Loading