Ruby Quiz - Challenge #17 - Build an HTML Template Engine Like It's 1999

Hello,

   It's Friday. Ruby Quiz time! [1] Join us for the second challenge in
the new year in 2020! Here we go:

  Challenge #17 - Build an HTML Template Engine Like It's 1999 [2]

Remember the time before Facebook? It was all about blogs
and building your own newsfeed from blog rolls.

The most popular newsfeed tool in 1999 was called Planet
and used the HTML template language / engine.

Let's say you have the following blog roll / channels
defined in ruby:

Blogroll = Struct.new( :name, :owner_name, :date_822, :channels )
Channel  = Struct.new( :name, :url, :channel_link )

blogroll = Blogroll.new( 'OpenStreetMap Blogs',
                         'OpenStreetMap',
                          Date.new( 2020, 2, 7 ).rfc2822,
                         [Channel.new( 'Shaun McDonald',
                                       'http://blog.shaunmcdonald.me.uk/feed/',
                                       'http://blog.shaunmcdonald.me.uk/' ),
                          Channel.new( 'Mapbox',

'https://blog.mapbox.com/feed/tagged/openstreetmap/',
                                       'https://blog.mapbox.com/' ),
                          Channel.new( 'Mapillary',
                                       'https://blog.mapillary.com/rss.xml',
                                       'https://blog.mapillary.com' ),
                          Channel.new( 'Richard Fairhurst',
                                       'http://blog.systemed.net/rss',
                                       'http://blog.systemed.net/' )]
                        )

pp blogroll

pretty printing to:

#<struct Blogroll
   name="OpenStreetMap Blogs",
   owner_name="OpenStreetMap",
   date_822="Fri, 7 Feb 2020 00:00:00 +0000",
   channels=
   [#<struct Channel
       name="Shaun McDonald",
       url="http://blog.shaunmcdonald.me.uk/feed/",
       channel_link="http://blog.shaunmcdonald.me.uk/">,
    #<struct Channel
       name="Mapbox",
       url="https://blog.mapbox.com/feed/tagged/openstreetmap/",
       channel_link="https://blog.mapbox.com/">,
    #<struct Channel
       name="Mapillary",
       url="https://blog.mapillary.com/rss.xml",
       channel_link="https://blog.mapillary.com">,
    #<struct Channel
       name="Richard Fairhurst",
       url="http://blog.systemed.net/rss",
       channel_link="http://blog.systemed.net/">]>

Let's build an HTML template engine like it's 1999.
Example:

<?xml version="1.0"?>
<opml version="1.1">
  <head>
    <title><TMPL_VAR name></title>
    <dateModified><TMPL_VAR date_822></dateModified>
    <ownerName><TMPL_VAR owner_name></ownerName>
  </head>

  <body>
    <TMPL_LOOP channels>
    <outline type="rss"
             text="<TMPL_VAR name>"
             xmlUrl="<TMPL_VAR url>"
             <TMPL_IF channel_link> htmlUrl="<TMPL_VAR
channel_link>"</TMPL_IF> />
    </TMPL_LOOP>
  </body>
</opml>

In the starter level handle variables, conditionals, and loops.
Syntax overview:

**1) `<TMPL_VAR identifier>`**

<TMPL_VAR name>
<TMPL_VAR date_822>

**2) `<TMPL_IF identifier>..</TMPL_IF>`**

<TMPL_IF channel_link> htmlUrl="<TMPL_VAR channel_link>" </TMPL_IF>

**3) `<TMPL_LOOP identifier>..</TMPL_LOOP>`**

<TMPL_LOOP channels>
  <outline type="rss"
             text="<TMPL_VAR name>"
             xmlUrl="<TMPL_VAR url>"
             <TMPL_IF channel_link> htmlUrl="<TMPL_VAR
channel_link>"</TMPL_IF> />
</TMPL_LOOP>

Note: Inside loops you have
to auto-add the loop context e.g. `channel` to make
variables or conditionals work e.g. `name`
becomes `[channel.]name` and `channel_link` becomes `[channel.]channel_link`
and so on.

The challenge: Code a `merge`
method that merges the passed in HTML template
and the blogroll into a merged XML document
that passes the RubyQuizTest :-).

def merge( template, blogroll )
  # ...
end

Resulting in:

<?xml version="1.0"?>
<opml version="1.1">
  <head>
    <title>OpenStreetMap Blogs</title>
    <dateModified>Fri, 7 Feb 2020 00:00:00 +0000</dateModified>
    <ownerName>OpenStreetMap</ownerName>
  </head>

  <body>

    <outline type="rss"
             text="Shaun McDonald"
             xmlUrl="http://blog.shaunmcdonald.me.uk/feed/"
             htmlUrl="http://blog.shaunmcdonald.me.uk/" />

    <outline type="rss"
             text="Mapbox"
             xmlUrl="https://blog.mapbox.com/feed/tagged/openstreetmap/"
             htmlUrl="https://blog.mapbox.com/" />

    <outline type="rss"
             text="Mapillary"
             xmlUrl="https://blog.mapillary.com/rss.xml"
             htmlUrl="https://blog.mapillary.com" />

    <outline type="rss"
             text="Richard Fairhurst"
             xmlUrl="http://blog.systemed.net/rss"
             htmlUrl="http://blog.systemed.net/" />

  </body>
</opml>

Note: For the test the xml document gets automatically
reformatted
and pretty printed in compact style with two-space indent
so you do NOT have to worry about spaces, line breaks, quotes or indentation.
Reformatted example:

<opml version='1.1'>
  <head>
    <title>OpenStreetMap Blogs</title>
    <dateModified>Fri, 7 Feb 2020 00:00:00 +0000</dateModified>
    <ownerName>OpenStreetMap</ownerName>
  </head>
  <body>
    <outline type='rss' text='Shaun McDonald'
xmlUrl='http://blog.shaunmcdonald.me.uk/feed/'
htmlUrl='http://blog.shaunmcdonald.me.uk/'/>
    <outline type='rss' text='Mapbox'
xmlUrl='https://blog.mapbox.com/feed/tagged/openstreetmap/'
htmlUrl='https://blog.mapbox.com/'/>
    <outline type='rss' text='Mapillary'
xmlUrl='https://blog.mapillary.com/rss.xml'
htmlUrl='https://blog.mapillary.com'/>
    <outline type='rss' text='Richard Fairhurst'
xmlUrl='http://blog.systemed.net/rss'
htmlUrl='http://blog.systemed.net/'/>
  </body>
</opml>

To qualify for solving the code challenge / puzzle you must pass the test:

require 'minitest/autorun'

class RubyQuizTest < MiniTest::Test
  Blogroll = Struct.new( :name, :owner_name, :date_822, :channels )
  Channel  = Struct.new( :name, :url, :channel_link )

  def blogroll
     Blogroll.new( 'OpenStreetMap Blogs',
                   'OpenStreetMap',
                    Date.new( 2020, 2, 7 ).rfc2822,
                   [Channel.new( 'Shaun McDonald',
                                 'http://blog.shaunmcdonald.me.uk/feed/',
                                 'http://blog.shaunmcdonald.me.uk/' ),
                    Channel.new( 'Mapbox',

'https://blog.mapbox.com/feed/tagged/openstreetmap/',
                                 'https://blog.mapbox.com/' ),
                    Channel.new( 'Mapillary',
                                 'https://blog.mapillary.com/rss.xml',
                                 'https://blog.mapillary.com' ),
                    Channel.new( 'Richard Fairhurst',
                                 'http://blog.systemed.net/rss',
                                 'http://blog.systemed.net/' )]
                  )
  end

  def test_merge
    template      = File.open( "./opml/opml.xml.tmpl", "r:utf-8" ).read
    xml           = File.open( "./opml/opml.xml",      "r:utf-8" ).read

    assert_equal prettify_xml( xml ),
                 prettify_xml( merge( template, blogroll ))
  end

  ## xml helper
  def prettify_xml( xml )
      d = REXML::Document.new( xml )

      formatter = REXML::Formatters::Pretty.new( 2 )  # indent=2
      formatter.compact = true # This is the magic line that does what you need!
      formatter.write( d.root, '' )
  end
end

Start from scratch or, yes, use any library / gem you can find.

Post your code snippets on the "official" Ruby Quiz Channel,
that is, ruby-talk right here.

Happy text processing and template merging with Ruby.

[1] https://github.com/planetruby/quiz
[2] https://github.com/planetruby/quiz/tree/master/017

Gerald Bauer wrote:

Challenge #17 - Build an HTML Template Engine Like It's 1999

$ ruby test.rb
Run options: --seed 38736
# Running:
.
Finished in 0.018744s, 53.3502 runs/s, 53.3502 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

$ cat test.rb
...
def merge( template, blogroll )
  require 'erb'
  qux = ERB.new(erbify(template), nil, '-')
  qux.result_with_hash({opml: blogroll}) #tap{|r| puts r}
end
def erbify(t)
  t.gsub(/<TMPL_VAR (\w+)>/, '<%= item.\1 rescue opml.\1 -%>')
   .gsub(/<TMPL_LOOP (\w+)>/, '<% for item in opml.\1 -%>')
   .gsub(/<\/TMPL_LOOP>/, '<% end -%>')
   .gsub(/<TMPL_IF (\w+)>/, '<% if (item.\1 rescue opml.\1) -%>')
   .gsub(/<\/TMPL_IF>/, '<% end -%>')
end
...

Hello,

   Wow. Thanks for sharing your great Ruby Quiz solution. I can't
believe how you squeezed everything into less than 10 lines of ruby.
Maybe I should upgrade from ruby 2.3 to ruby 2.5 for the
ERB#result_with_hash shortcut :-).

   For reference here's my monster I used for testing the Ruby Quiz:

def merge( template, blogroll )
  HtmlTemplate.new( template ).render( name:       blogroll.name,
                                       owner_name: blogroll.owner_name,
                                       date_822:   blogroll.date_822,
                                       channels:   blogroll.channels )
end

  Okkie, just kidding :-). Here's the HtmlTemplate monster for real:

class HtmlTemplate
  def initialize( text )
    @template = ERB.new( convert( text ) )
  end

  VAR_RE = %r{<TMPL_(?<tag>VAR)
                 \s
                 (?<ident>[a-zA-Z_0-9]+)
               >}x

  IF_OPEN_RE = %r{(?<open><)TMPL_(?<tag>IF)
                  \s
                  (?<ident>[a-zA-Z_0-9]+)
                >}x

  IF_CLOSE_RE = %r{(?<close></)TMPL_(?<tag>IF)
                  >}x

  LOOP_OPEN_RE = %r{(?<open><)TMPL_(?<tag>LOOP)
                      \s
                      (?<ident>[a-zA-Z_0-9]+)
                    >}x

  LOOP_CLOSE_RE = %r{(?<close></)TMPL_(?<tag>LOOP)
                      >}x

  ALL_RE = Regexp.union( VAR_RE,
                         IF_OPEN_RE,
                         IF_CLOSE_RE,
                         LOOP_OPEN_RE,
                         LOOP_CLOSE_RE )

  def convert( text )
    stack = []

    text =  text.gsub( ALL_RE ) do |_|
      m = $~    ## (global) last match object

      tag         = m[:tag]
      tag_open    = m[:open]
      tag_close   = m[:close]

      ident       = m[:ident]

      ctx         = stack[-1]   ## peek; get top stack item

      code = if tag == 'VAR'
                "<%= #{ctx}.#{ident} %>"
             elsif tag == 'LOOP' && tag_open
                ## assume plural ident e.g. channels
                ##  cut-off last char, that is, the plural s channels => channel
                stack.push( ident[0..-2] )
                "<% #{ctx}.#{ident}.each do |#{ident[0..-2]}| %>"
             elsif tag == 'LOOP' && tag_close
                stack.pop
                "<% end %>"
             elsif tag == 'IF' && tag_open
                "<% if #{ctx}.#{ident} then %>"
             elsif tag == 'IF' && tag_close
                "<% end %>"
             else
                 raise ArgumentError  ## unknown tag #{tag}
             end
      puts " match #{m[0]} replacing with: #{code}"

      code
    end
    text
  end

  class Context < OpenStruct
    def get_binding() binding; end
  end

  def render( **kwargs )
      ## note: Ruby >= 2.5 has ERB#result_with_hash - use later - why? why not?
    @template.result( Context.new( **kwargs ).get_binding )
  end
end

  I was kind of worried this time it's too hard. Great to see proven
wrong. Thanks again for posting your script. Have a great weekend.
Cheers.