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