src/generate-gallery-pages.lua
1#!/usr/bin/env luajit
2
3-- {{{ generate-gallery-pages.lua
4-- Issue 10-042a: Generate HTML gallery pages for standalone images
5-- Creates gallery index and per-source gallery pages from image-catalog.json
6--
7-- Usage:
8-- luajit src/generate-gallery-pages.lua [DIR]
9--
10-- Output:
11-- output/gallery/index.html - Gallery index listing all sources
12-- output/gallery/my-art.html - Gallery for my-art images
13-- output/gallery/poem-pictures.html - Gallery for poem-pictures images
14-- output/gallery/things-i-almost-posted.html - Gallery for things-i-almost-posted
15-- output/gallery/dnd-pictures.html - Gallery for dnd-pictures
16-- output/gallery/fediverse-stars.html - Gallery for fediverse-stars
17--
18-- Note: fediverse-media is excluded as those images are inline with poems
19-- }}}
20
21-- {{{ setup_dir_path
22local function setup_dir_path(provided_dir)
23 if provided_dir then
24 return provided_dir
25 end
26 return "/mnt/mtwo/programming/ai-stuff/neocities-modernization"
27end
28-- }}}
29
30-- {{{ parse_args
31local function parse_args(args)
32 local dir = nil
33 local i = 1
34 while i <= #(args or {}) do
35 local a = args[i]
36 if not a:match("^%-") then
37 dir = a
38 i = i + 1
39 else
40 i = i + 1
41 end
42 end
43 return dir
44end
45-- }}}
46
47local provided_dir = parse_args(arg)
48local DIR = setup_dir_path(provided_dir)
49package.path = DIR .. "/libs/?.lua;" .. DIR .. "/src/?.lua;" .. package.path
50
51local dkjson = require("dkjson")
52local utils = require("utils")
53-- Issue 10-042d / 9-013: shared "source: sub: name.png" title helper
54local image_titles = require("image-pseudo-embeddings")
55utils.init_assets_root(arg)
56
57-- Issue 10-003: Load unified config from config.lua
58local config_loader = require("config-loader")
59config_loader.set_project_root(DIR)
60local config = config_loader.load()
61
62local M = {}
63
64-- {{{ Configuration
65-- Sources to include in gallery (exclude fediverse-media which is inline with poems)
66local STANDALONE_SOURCES = {
67 "my-art",
68 "things-I-almost-posted",
69 "poem-pictures",
70 "dnd-pictures-from-the-internet",
71 "fediverse-stars"
72}
73
74-- Map source names to URL-friendly slugs
75local SOURCE_SLUGS = {
76 ["my-art"] = "my-art",
77 ["things-I-almost-posted"] = "things-i-almost-posted",
78 ["poem-pictures"] = "poem-pictures",
79 ["dnd-pictures-from-the-internet"] = "dnd-pictures",
80 ["fediverse-stars"] = "fediverse-stars"
81}
82
83-- Map source names to display titles
84local SOURCE_TITLES = {
85 ["my-art"] = "My Art",
86 ["things-I-almost-posted"] = "Things I Almost Posted",
87 ["poem-pictures"] = "Poem Pictures",
88 -- Keep the full source name so it is clear these were found, not authored
89 ["dnd-pictures-from-the-internet"] = "dnd-pictures-from-the-internet",
90 ["fediverse-stars"] = "Fediverse Stars"
91}
92
93-- Grid layout
94local COLUMNS = 4
95local THUMBNAIL_WIDTH = 200
96local MASONRY_GAP = 16 -- px between COLUMNS (horizontal)
97local MASONRY_VGAP = 8 -- px between stacked images WITHIN a column (vertical) -- tight pack
98-- }}}
99
100-- {{{ load_image_catalog
101local function load_image_catalog()
102 local catalog_path = DIR .. "/assets/image-catalog.json"
103 local file = io.open(catalog_path, "r")
104 if not file then
105 print("Error: Could not open " .. catalog_path)
106 return nil
107 end
108
109 local content = file:read("*a")
110 file:close()
111
112 local data, pos, err = dkjson.decode(content)
113 if err then
114 print("Error parsing image catalog: " .. tostring(err))
115 return nil
116 end
117
118 return data
119end
120-- }}}
121
122-- {{{ filter_standalone_images
123-- Filter to only standalone images (exclude fediverse-media)
124local function filter_standalone_images(catalog)
125 local standalone = {}
126 local standalone_set = {}
127 for _, source in ipairs(STANDALONE_SOURCES) do
128 standalone_set[source] = true
129 end
130
131 for _, img in ipairs(catalog.images or {}) do
132 if standalone_set[img.source_name] then
133 table.insert(standalone, img)
134 end
135 end
136
137 return standalone
138end
139-- }}}
140
141-- {{{ group_by_source
142-- Group images by their source_name
143local function group_by_source(images)
144 local groups = {}
145 for _, img in ipairs(images) do
146 local source = img.source_name
147 if not groups[source] then
148 groups[source] = {}
149 end
150 table.insert(groups[source], img)
151 end
152 return groups
153end
154-- }}}
155
156-- {{{ url_encode_path
157-- Percent-encode a relative URL path so filenames containing spaces, ?, #, %,
158-- parentheses, etc. don't break the href/src. Path separators (/) and the safe
159-- set [A-Za-z0-9-._~] are preserved, so the "../../gallery/..." structure still
160-- resolves; only the unsafe bytes inside each segment are escaped. Without this
161-- a space silently truncated the src and the browser drew a broken-image icon.
162local function url_encode_path(path)
163 return (path:gsub("[^%w%-%._~/]", function(c)
164 return string.format("%%%02X", string.byte(c))
165 end))
166end
167-- }}}
168
169-- {{{ get_relative_image_path
170-- Convert absolute path to a URL-safe relative path from output/gallery/.
171local function get_relative_image_path(absolute_path)
172 -- Reference the copy in output/media/, not the original under input/. The
173 -- original under input/ is never uploaded, so a "../../input/images/..." path
174 -- 404s in production. Gallery pages live in output/gallery/, so a "../media/"
175 -- relative path reaches the copy both locally and deployed (both under
176 -- /similar-different/) with no rewrite.
177 --
178 -- LAYOUT must match flatten_media_files + media_href in the other generators:
179 -- art images keep their <source>/<subpath> (so my-art/x.png and
180 -- my-art/game-design/x.png don't collide into one output/media/x.png);
181 -- Mastodon hashes collapse to the bare basename. url_encode_path preserves
182 -- the slashes between segments.
183 local path = absolute_path or ""
184 local sub = path:match("input/images/(.+)$") or (path:match("([^/]+)$") or path)
185 return url_encode_path("../media/" .. sub)
186end
187-- }}}
188
189-- {{{ caption_with_breaks
190-- Render a filename as a thumbnail caption that WRAPS on dashes/underscores
191-- instead of being chopped to 20 chars + "...". We HTML-escape the name, then
192-- insert <wbr> (a zero-width break opportunity) after each - or _ so a long
193-- name like "dnd-pictures-from-the-internet-04" folds onto several lines inside
194-- the thumbnail column rather than truncating or overflowing. The caller wraps
195-- this in a width-constrained span so the breaks actually engage.
196local function caption_with_breaks(filename)
197 local safe = filename:gsub("&", "&"):gsub("<", "<"):gsub(">", ">")
198 return (safe:gsub("([%-_])", "%1<wbr>"))
199end
200-- }}}
201
202-- {{{ extract_display_name
203-- Extract a display name from filename (used as alt text)
204local function extract_display_name(filename)
205 -- Remove extension
206 local name = filename:match("^(.+)%.[^%.]+$") or filename
207 -- Convert dashes/underscores to spaces
208 name = name:gsub("[%-_]", " ")
209 -- Title case (capitalize first letter of each word)
210 name = name:gsub("(%a)([%w]*)", function(first, rest)
211 return first:upper() .. rest:lower()
212 end)
213 return name
214end
215-- }}}
216
217-- {{{ generate_html_header
218local function generate_html_header(title)
219 local theme = config.html_theme or {}
220 local bg = theme.background or "#000000"
221 local text = theme.text or "#FFFFFF"
222 local link = theme.link or "#6699FF"
223 local vlink = theme.vlink or "#9966FF"
224
225 return string.format([[<!DOCTYPE html>
226<html>
227<head>
228 <meta charset="UTF-8">
229 <meta name="viewport" content="width=device-width, initial-scale=1.0">
230 <title>%s</title>
231</head>
232<body bgcolor="%s" text="%s" link="%s" vlink="%s">
233<center>
234]], title, bg, text, link, vlink)
235end
236-- }}}
237
238-- {{{ generate_html_footer
239local function generate_html_footer()
240 return [[
241</center>
242</body>
243</html>
244]]
245end
246-- }}}
247
248-- {{{ generate_gallery_grid
249-- Generate a MASONRY layout for images. The old fixed <table> grid forced every
250-- row to the height of its tallest image, so portrait/landscape mixes left big
251-- ragged vertical gaps. CSS multi-column layout packs each column independently
252-- (an item flows under the previous one in its column), giving uniform ~18px
253-- gaps and no wasted space -- and needs no JavaScript, so it still works as a
254-- plain neocities page. break-inside:avoid keeps an image and its caption
255-- together rather than splitting them across a column boundary.
256local function generate_gallery_grid(images)
257 local html = {}
258 -- Cap the masonry to COLUMNS columns and center the whole block. column-width
259 -- (not column-count) lets it gracefully drop to fewer columns on narrow
260 -- screens while holding the thumbnail size.
261 -- Exactly COLUMNS columns (the user wants four, always). Each column is an
262 -- independent vertical stack: an item flows directly under the previous one
263 -- in its column, no row alignment -- that is what CSS multi-column does. The
264 -- container max-width keeps the columns near the thumbnail size and centers
265 -- the block. Items are display:block (not inline-block, which would add a
266 -- baseline gap) so they pack as close as MASONRY_VGAP allows.
267 local container_max = (THUMBNAIL_WIDTH + MASONRY_GAP) * COLUMNS
268 table.insert(html, string.format(
269 '<div style="column-count:%d; column-gap:%dpx; max-width:%dpx; margin:0 auto;">\n',
270 COLUMNS, MASONRY_GAP, container_max))
271
272 for _, img in ipairs(images) do
273 local rel_path = get_relative_image_path(img.file_path)
274 local alt_text = extract_display_name(img.filename)
275 table.insert(html, string.format(
276 ' <div style="display:block; margin:0 0 %dpx; text-align:center; ' ..
277 'break-inside:avoid; -webkit-column-break-inside:avoid;">' ..
278 '<a href="%s"><img src="%s" alt="%s" title="%s" loading="lazy" border="1" ' ..
279 'style="width:100%%; height:auto; display:block;"></a>' ..
280 '<font size="1"><span style="display:inline-block; max-width:%dpx; ' ..
281 'word-wrap:break-word; overflow-wrap:break-word;">%s</span></font>' ..
282 '</div>\n',
283 MASONRY_VGAP, rel_path, rel_path, alt_text, alt_text,
284 THUMBNAIL_WIDTH, caption_with_breaks(img.filename)
285 ))
286 end
287
288 table.insert(html, '</div>\n')
289 return table.concat(html)
290end
291-- }}}
292
293-- {{{ generate_source_gallery
294-- Generate a gallery page for a specific source
295local function generate_source_gallery(source_name, images)
296 local slug = SOURCE_SLUGS[source_name] or source_name:lower():gsub("%s+", "-")
297 local title = SOURCE_TITLES[source_name] or source_name
298
299 local html = {}
300 table.insert(html, generate_html_header("Gallery: " .. title))
301
302 -- Navigation
303 table.insert(html, '<p>')
304 table.insert(html, '<a href="../wordcloud.html">Menu</a> | ')
305 table.insert(html, '<a href="index.html">Gallery Index</a>')
306 table.insert(html, '</p>\n')
307
308 -- Title
309 table.insert(html, '<h1>' .. title .. '</h1>\n')
310 table.insert(html, '<p>' .. #images .. ' images</p>\n')
311 table.insert(html, '<hr width="80%">\n')
312
313 -- Grid
314 table.insert(html, generate_gallery_grid(images))
315
316 -- Footer nav
317 table.insert(html, '<hr width="80%">\n')
318 table.insert(html, '<p><a href="index.html">Back to Gallery Index</a></p>\n')
319
320 table.insert(html, generate_html_footer())
321
322 return table.concat(html), slug .. ".html"
323end
324-- }}}
325
326-- {{{ generate_gallery_index
327-- Generate the main gallery index page
328local function generate_gallery_index(grouped_images)
329 local html = {}
330 table.insert(html, generate_html_header("Gallery"))
331
332 -- Navigation
333 table.insert(html, '<p>')
334 table.insert(html, '<a href="../wordcloud.html">Menu</a> | ')
335 table.insert(html, '<a href="../explore.html">Explore</a> | ')
336 table.insert(html, '<a href="chronological.html">Chronological</a>')
337 table.insert(html, '</p>\n')
338
339 -- Title
340 table.insert(html, '<h1>Image Gallery</h1>\n')
341
342 -- Count total
343 local total = 0
344 for _, images in pairs(grouped_images) do
345 total = total + #images
346 end
347 table.insert(html, '<p>' .. total .. ' standalone images across ' .. #STANDALONE_SOURCES .. ' collections</p>\n')
348 table.insert(html, '<hr width="80%">\n')
349
350 -- Source list with representative thumbnails
351 table.insert(html, '<table border="0" cellpadding="20" cellspacing="10">\n')
352
353 for _, source_name in ipairs(STANDALONE_SOURCES) do
354 local images = grouped_images[source_name]
355 if images and #images > 0 then
356 local slug = SOURCE_SLUGS[source_name] or source_name:lower():gsub("%s+", "-")
357 local title = SOURCE_TITLES[source_name] or source_name
358
359 -- Pick a representative image (first one)
360 local rep_img = images[1]
361 local rel_path = get_relative_image_path(rep_img.file_path)
362
363 table.insert(html, '<tr>\n')
364 table.insert(html, string.format(
365 ' <td align="center" valign="middle">' ..
366 '<a href="%s.html"><img src="%s" width="150" loading="lazy" border="1"></a></td>\n',
367 slug, rel_path
368 ))
369 table.insert(html, string.format(
370 ' <td valign="middle"><h2><a href="%s.html">%s</a></h2>' ..
371 '<p>%d images</p></td>\n',
372 slug, title, #images
373 ))
374 table.insert(html, '</tr>\n')
375 end
376 end
377
378 table.insert(html, '</table>\n')
379
380 -- Footer
381 table.insert(html, '<hr width="80%">\n')
382 table.insert(html, '<p><a href="../wordcloud.html">Back to Menu</a></p>\n')
383
384 table.insert(html, generate_html_footer())
385
386 return table.concat(html)
387end
388-- }}}
389
390-- {{{ image_qualified_title
391-- "source: sub: name.png" for a catalog image: strip the (absolute)
392-- source_directory prefix off relative_path, then delegate to the shared title
393-- helper that poem-page image entries also use (Issue 9-013 / 10-042d).
394local function image_qualified_title(img)
395 local full = img.relative_path or ""
396 local base = img.source_directory or ""
397 local rel = (base ~= "" and full:sub(1, #base) == base)
398 and (full:sub(#base + 1):gsub("^/+", ""))
399 or (img.filename or full)
400 return image_titles.qualified_image_title(img.source_name, rel)
401end
402-- }}}
403
404-- {{{ image_anchor
405-- Stable per-image anchor so poem pages can deep-link to one image here.
406local function image_anchor(img, fallback_index)
407 return "img-" .. (img.hash and img.hash:sub(1, 12) or tostring(fallback_index))
408end
409-- }}}
410
411-- {{{ generate_gallery_chronological
412-- Issue 10-042d: all standalone images, every source, in time order, as a
413-- vertical scroll. Between each pair of images is a caption block naming the
414-- image ABOVE and the image BELOW, so every title shows twice (once on each
415-- side of its picture). Each image is anchored for deep-linking from poems.
416local function generate_gallery_chronological(images)
417 table.sort(images, function(a, b)
418 return (tonumber(a.modification_time) or 0) < (tonumber(b.modification_time) or 0)
419 end)
420
421 local SEP = string.rep("─", 78)
422 local function esc(s) return (s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) end
423
424 local html = {}
425 table.insert(html, generate_html_header("Images — Chronological"))
426 table.insert(html, '<center>')
427 table.insert(html, '<h1>Images — Chronological</h1>')
428 table.insert(html, '<p><a href="index.html">Gallery Index</a> │ <a href="../index.html">Menu</a></p>')
429 table.insert(html, '<hr>')
430 table.insert(html, '<p>' .. #images .. ' images from all collections, in time order</p>')
431 table.insert(html, '</center>')
432
433 for i, img in ipairs(images) do
434 local rel = get_relative_image_path(img.relative_path)
435 local title = image_qualified_title(img)
436 table.insert(html, string.format(
437 '<a name="%s"></a><img src="%s" alt="%s" loading="lazy" style="max-width:min(100%%,800px); height:auto; display:block; margin:1em auto;">',
438 image_anchor(img, i), rel, esc(title)))
439 -- Caption block: sep, blank, title-of-image-above (this), blank,
440 -- title-of-image-below (next), blank, sep. The last image has no below.
441 local block = { SEP, "", esc(title), "" }
442 if images[i + 1] then
443 table.insert(block, esc(image_qualified_title(images[i + 1])))
444 end
445 table.insert(block, "")
446 table.insert(block, SEP)
447 table.insert(html, '<pre style="text-align:center">' .. table.concat(block, "\n") .. '</pre>')
448 end
449
450 table.insert(html, generate_html_footer())
451 return table.concat(html, "\n")
452end
453-- }}}
454
455-- {{{ M.generate
456function M.generate()
457 print("Loading image catalog...")
458 local catalog = load_image_catalog()
459 if not catalog then
460 return false
461 end
462
463 print("Filtering standalone images...")
464 local standalone = filter_standalone_images(catalog)
465 print("Found " .. #standalone .. " standalone images")
466
467 print("Grouping by source...")
468 local grouped = group_by_source(standalone)
469
470 -- Create output directory
471 local output_dir = DIR .. "/output/gallery"
472 os.execute("mkdir -p " .. output_dir)
473
474 -- Generate index page
475 print("Generating gallery index...")
476 local index_html = generate_gallery_index(grouped)
477 local index_file = io.open(output_dir .. "/index.html", "w")
478 if index_file then
479 index_file:write(index_html)
480 index_file:close()
481 print(" Created: output/gallery/index.html")
482 end
483
484 -- Issue 10-042d: chronological images page (all sources, by time)
485 print("Generating chronological images page...")
486 local chrono_html = generate_gallery_chronological(standalone)
487 local chrono_file = io.open(output_dir .. "/chronological.html", "w")
488 if chrono_file then
489 chrono_file:write(chrono_html)
490 chrono_file:close()
491 print(" Created: output/gallery/chronological.html")
492 end
493
494 -- Generate per-source gallery pages
495 for _, source_name in ipairs(STANDALONE_SOURCES) do
496 local images = grouped[source_name]
497 if images and #images > 0 then
498 print("Generating gallery for " .. source_name .. " (" .. #images .. " images)...")
499 local page_html, filename = generate_source_gallery(source_name, images)
500 local page_file = io.open(output_dir .. "/" .. filename, "w")
501 if page_file then
502 page_file:write(page_html)
503 page_file:close()
504 print(" Created: output/gallery/" .. filename)
505 end
506 else
507 print("Skipping " .. source_name .. " (no images)")
508 end
509 end
510
511 print("Gallery generation complete!")
512 return true
513end
514-- }}}
515
516-- Run if executed directly
517if arg and arg[0]:match("generate%-gallery%-pages%.lua$") then
518 M.generate()
519end
520
521return M
522