src/page-template.lua

120 lines

1#!/usr/bin/env lua
2
3-- Page-template substitution: fills {UPPERCASE_NAME} markers in a plain-text
4-- template with live values, so the *words* of a generated page can live in an
5-- editable input/ file while the *numbers* are still computed in code. This is
6-- the same idea report-generator.lua already used with {PLACEHOLDER} markers,
7-- generalized into one small reusable piece and made strict: an unresolved
8-- marker is an error (a typo on a page is worse than a loud failure at build),
9-- not a thing to silently leave on the page.
10--
11-- Why a separate module: the explore pages (Issue 11-005) read their prose from
12-- input/pages/*.txt. Keeping the substitutor here, with no dependency on the
13-- rest of the generator, means it can be unit-tested on its own and reused by
14-- any future page that wants editable copy.
15
16local M = {}
17
18-- A marker name is one or more UPPERCASE letters, digits, or underscores wrapped
19-- in braces: {TOTAL_POEMS}. Restricting to uppercase is deliberate -- prose and
20-- HTML never contain "{WORD}" in that exact shape, so ordinary text in the
21-- template is never mistaken for a marker. (Lowercase {x} is left untouched.)
22local MARKER = "{([A-Z0-9_]+)}"
23
24-- {{{ M.OMIT
25-- Sentinel value. Assign it to a marker (e.g. values.DATE_SPAN = M.OMIT) to make
26-- the ENTIRE template line that mentions that marker disappear -- no blank gap
27-- left behind. This reproduces the old "only show this stat line when the fact
28-- exists" behavior that bare string substitution cannot express. tostring() on
29-- it yields "" so an accidental render is harmless rather than printing a table.
30M.OMIT = setmetatable({}, { __tostring = function() return "" end })
31-- }}}
32
33-- {{{ local function line_mentions_omit()
34-- True when any marker on this single line resolves to the OMIT sentinel. A line
35-- "about" an omitted fact is dropped whole, which is what you want: the label and
36-- the (missing) value go together.
37local function line_mentions_omit(line, values)
38 for key in line:gmatch(MARKER) do
39 if values[key] == M.OMIT then
40 return true
41 end
42 end
43 return false
44end
45-- }}}
46
47-- {{{ function M.substitute()
48-- Fill `template` from `values` (a table of NAME -> string/number, or M.OMIT).
49-- Returns the filled string, or (nil, error_message) if the template names a
50-- marker that `values` has no entry for. Pass 1 removes OMIT lines; pass 2
51-- replaces the rest. % in a value is NOT special here because gsub's replacement
52-- is a function (function returns are used verbatim), so values can safely carry
53-- percent signs, which the old hand-escaped version had to guard against.
54function M.substitute(template, values)
55 -- Two real errors worth catching at the door rather than producing a
56 -- mangled page: a non-string template, or no value table at all.
57 if type(template) ~= "string" then
58 return nil, "page-template: template must be a string, got " .. type(template)
59 end
60 if type(values) ~= "table" then
61 return nil, "page-template: values must be a table, got " .. type(values)
62 end
63
64 -- Pass 1: drop every line that mentions an OMIT-valued marker. Appending a
65 -- trailing newline before splitting, then re-joining with newlines, leaves
66 -- the original line structure intact (including a final no-newline line).
67 local kept = {}
68 for line in (template .. "\n"):gmatch("(.-)\n") do
69 if not line_mentions_omit(line, values) then
70 kept[#kept + 1] = line
71 end
72 end
73 local body = table.concat(kept, "\n")
74
75 -- Pass 2: replace each surviving marker. Any marker with no value is recorded
76 -- and, once the whole pass is done, reported as an error. We collect them all
77 -- so a person fixing a template sees every typo at once, not one per rebuild.
78 local missing, seen = {}, {}
79 local filled = body:gsub(MARKER, function(key)
80 local value = values[key]
81 if value == nil then
82 if not seen[key] then
83 seen[key] = true
84 missing[#missing + 1] = key
85 end
86 return "{" .. key .. "}" -- leave it; we are about to error anyway
87 end
88 if value == M.OMIT then
89 return "" -- a stray OMIT marker not alone on its line; render empty
90 end
91 return tostring(value)
92 end)
93
94 if #missing > 0 then
95 table.sort(missing)
96 return nil, "page-template: no value for placeholder(s): {"
97 .. table.concat(missing, "}, {") .. "}"
98 end
99
100 return filled
101end
102-- }}}
103
104-- {{{ function M.render_file()
105-- Read a template from `path` and substitute. Uses io.open directly so the
106-- module carries no dependency on the project's utils. Returns the filled
107-- string, or (nil, error_message) on a missing file or any unresolved marker.
108function M.render_file(path, values)
109 local file = io.open(path, "r")
110 if not file then
111 return nil, "page-template: could not open template file: " .. tostring(path)
112 end
113 local content = file:read("*a")
114 file:close()
115 return M.substitute(content, values)
116end
117-- }}}
118
119return M
120