src/wordcloud-generator.lua

636 lines

1#!/usr/bin/env luajit
2
3-- {{{ wordcloud-generator.lua
4-- Issue 8-043: Generate semantic word cloud page
5-- Extracts words from poems, filters stop words, and creates a visual word cloud
6-- where font size represents word frequency (or optionally, centroid similarity)
7--
8-- Usage:
9-- luajit src/wordcloud-generator.lua [DIR] [--all] [--words N]
10-- luajit src/wordcloud-generator.lua --help
11--
12-- Options:
13-- --all Include all words (no max_words limit)
14-- --words N Set maximum words to display (default: 200 from config)
15-- }}}
16
17-- {{{ Setup
18local function setup_dir_path(provided_dir)
19 if provided_dir then
20 return provided_dir
21 end
22 return "/mnt/mtwo/programming/ai-stuff/neocities-modernization"
23end
24
25-- {{{ parse_args
26-- Parse command line arguments for DIR and word cloud options
27local function parse_args(args)
28 local dir = nil
29 local all_words = false
30 local max_words = nil -- nil means use config default
31 local chrono_per_page = nil -- nil means fall back to config (never to a literal)
32 local seed = nil -- Issue 10-058: master seed for the shuffle; nil => auto at startup
33 local i = 1
34 while i <= #(args or {}) do
35 local a = args[i]
36 if a == "--all" then
37 all_words = true
38 i = i + 1
39 elseif a == "--seed" then
40 -- Issue 10-058: the build's master seed, threaded from run.sh so the
41 -- word order is reproducible. Both "--seed N" and "--seed=N" forms.
42 seed = tonumber(args[i + 1])
43 i = i + 2
44 elseif a:match("^--seed=") then
45 seed = tonumber(a:match("^--seed=(.+)$"))
46 i = i + 1
47 elseif a == "--words" then
48 -- Accept "all" as a synonym for --all (the two flags are combined).
49 if args[i + 1] == "all" then all_words = true else max_words = tonumber(args[i + 1]) end
50 i = i + 2
51 elseif a:match("^--words=") then
52 local v = a:match("^--words=(.+)$")
53 if v == "all" then all_words = true else max_words = tonumber(v) end
54 i = i + 1
55 elseif a == "--chrono-per-page" then
56 -- The chronological page size the SAME build used, threaded from
57 -- run.sh so this separate process paginates poem links identically.
58 chrono_per_page = tonumber(args[i + 1])
59 i = i + 2
60 elseif a:match("^--chrono%-per%-page=") then
61 chrono_per_page = tonumber(a:match("=(.+)$"))
62 i = i + 1
63 elseif not a:match("^%-") then
64 -- Positional argument (DIR)
65 dir = a
66 i = i + 1
67 else
68 -- Skip unknown flags
69 i = i + 1
70 end
71 end
72 return dir, all_words, max_words, chrono_per_page, seed
73end
74-- }}}
75
76local provided_dir, CLI_ALL_WORDS, CLI_MAX_WORDS, CLI_CHRONO_PER_PAGE, CLI_SEED = parse_args(arg)
77local DIR = setup_dir_path(provided_dir)
78
79-- {{{ Issue 10-058: resolve + apply the master seed ONCE at startup
80-- The word shuffle used to call math.randomseed(os.time()) inside the shuffle on
81-- every invocation -- non-reproducible (the seed was never recorded) and, because
82-- os.time() has 1-second resolution, two shuffles in the same second drew the SAME
83-- "random" order. Now the seed is resolved once here and the shuffle just consumes
84-- the already-seeded stream.
85-- --seed N => run.sh passes the build's recorded master seed (the normal path).
86-- no flag => standalone run: invent a seed from the clock mixed with the PID
87-- (so back-to-back same-second runs differ) and LOG it, since here
88-- there is no run.sh to record it to generation-metadata.json.
89local MASTER_SEED = CLI_SEED
90if not MASTER_SEED then
91 -- LuaJIT has no portable getpid(), so for the per-process entropy that keeps
92 -- two same-second runs from drawing the same seed we use the hex address of a
93 -- fresh table -- distinct per process like a PID would be. Mixed with the
94 -- 1-second clock and folded into a 31-bit non-negative int (run.sh's range).
95 local process_unique_bits = tonumber(tostring({}):match("0x(%x+)") or "0", 16) or 0
96 MASTER_SEED = (os.time() * 100000 + process_unique_bits) % 2147483647
97 io.stderr:write(string.format(
98 "[wordcloud] no --seed given; using auto seed %d (pass --seed N to reproduce)\n",
99 MASTER_SEED))
100end
101math.randomseed(MASTER_SEED)
102-- }}}
103package.path = DIR .. "/libs/?.lua;" .. DIR .. "/src/?.lua;" .. package.path
104
105local dkjson = require("dkjson")
106local utils = require("utils")
107-- Shared chronological mapping so the poem-ID jump links here resolve to the
108-- SAME paginated page the chronological pages emit (a third inline copy used to
109-- drift -- see Issue 10-049 follow-up).
110local flat_html = require("flat-html-generator")
111utils.init_assets_root(arg)
112
113-- Issue 10-003: Load unified config from config.lua
114local config_loader = require("config-loader")
115config_loader.set_project_root(DIR)
116local unified_config = config_loader.load()
117
118-- {{{ resolve_chrono_per_page()
119-- The chronological page size used to map each poem ID to the page it lives on.
120-- Two legitimate sources, in order: the --chrono-per-page the build passed us,
121-- else the config value (default_chrono_per_page, which itself hard-errors if
122-- the config key is missing). There is deliberately no literal fallback -- a
123-- wrong size sends every poem link to the wrong page, so an absent value is an
124-- error, not a guess.
125local function resolve_chrono_per_page()
126 return CLI_CHRONO_PER_PAGE or flat_html.default_chrono_per_page()
127end
128-- }}}
129-- }}}
130
131local M = {}
132
133-- {{{ Configuration
134-- Issue 10-003: Load word_cloud config from unified config (including embedded stop_words)
135local wc = unified_config.word_cloud or {}
136
137-- Determine max_words: CLI --all > CLI --words > config
138local effective_max_words
139if CLI_ALL_WORDS then
140 effective_max_words = math.huge -- No limit
141elseif CLI_MAX_WORDS then
142 effective_max_words = CLI_MAX_WORDS
143else
144 effective_max_words = wc.max_words or 200
145end
146
147local CONFIG = {
148 min_occurrences = wc.min_occurrences or 5,
149 max_words = effective_max_words,
150 font_size_min = wc.font_size_min or 1,
151 font_size_max = wc.font_size_max or 7,
152 min_word_length = wc.min_word_length or 3,
153 output_file = wc.output_file or "wordcloud.html"
154}
155-- }}}
156
157-- {{{ load_stop_words
158-- Issue 10-003: Load stop words from embedded config.word_cloud.stop_words array
159local function load_stop_words()
160 local stop_words = {}
161
162 -- Load from config (array of words)
163 local config_stop_words = wc.stop_words or {}
164 for _, word in ipairs(config_stop_words) do
165 stop_words[word:lower()] = true
166 end
167
168 local count = 0
169 for _ in pairs(stop_words) do count = count + 1 end
170 utils.log_info(string.format("Loaded %d stop words from config", count))
171
172 return stop_words
173end
174-- }}}
175
176-- {{{ load_word_colors
177-- Issue 16-010: Load word colors from embeddings directory for colorized word cloud display
178local function load_word_colors()
179 local cache_file = utils.embeddings_dir() .. "/word_colors.json"
180 local data = utils.read_json_file(cache_file)
181 if data and data.word_colors then
182 local lookup = {}
183 for _, entry in ipairs(data.word_colors) do
184 -- Keep the WHOLE entry (best `.color` plus the full `.colors` ranking),
185 -- so the renderer can pick a large word's strongest non-gray color.
186 lookup[entry.word] = entry
187 end
188 utils.log_info(string.format("Loaded %d word colors from cache", #data.word_colors))
189 return lookup
190 end
191 utils.log_warn("No word colors found - words will display in default color")
192 return {}
193end
194-- }}}
195
196-- {{{ top_nongray_color()
197-- The word cloud colors LARGE words by meaning but must never render them gray --
198-- gray is reserved for the de-emphasised small words below the size threshold. Each
199-- word's color entry carries the full palette ranking (strongest first); walk it for
200-- the strongest color that is not gray. With six non-gray colors there is always one;
201-- the trailing fallbacks only guard a missing entry or a pre-`colors` cache (an old
202-- word_colors.json without the ranking, until it is regenerated). Returns a color
203-- NAME or nil.
204local function top_nongray_color(entry)
205 if entry and entry.colors then
206 for _, c in ipairs(entry.colors) do
207 if c.color ~= "gray" then return c.color end
208 end
209 end
210 -- No ranking available: fall back to the single best color (may be gray).
211 return entry and entry.color or nil
212end
213-- }}}
214
215-- {{{ extract_words_from_poems
216local function extract_words_from_poems(poems, stop_words)
217 local word_counts = {}
218 local total_words = 0
219
220 for _, poem in ipairs(poems) do
221 local content = poem.content or ""
222
223 -- Extract words (alphanumeric sequences)
224 for word in content:gmatch("[%w]+") do
225 local normalized = word:lower()
226
227 -- Filter: minimum length, not a stop word, not a number
228 if #normalized >= CONFIG.min_word_length
229 and not stop_words[normalized]
230 and not normalized:match("^%d+$") then
231 word_counts[normalized] = (word_counts[normalized] or 0) + 1
232 total_words = total_words + 1
233 end
234 end
235 end
236
237 local unique_count = 0
238 for _ in pairs(word_counts) do unique_count = unique_count + 1 end
239 utils.log_info(string.format("Extracted %d total words, %d unique",
240 total_words, unique_count))
241
242 return word_counts
243end
244-- }}}
245
246-- {{{ filter_and_sort_words
247local function filter_and_sort_words(word_counts)
248 local filtered = {}
249
250 -- Filter by minimum occurrences
251 for word, count in pairs(word_counts) do
252 if count >= CONFIG.min_occurrences then
253 table.insert(filtered, {word = word, count = count})
254 end
255 end
256
257 -- Sort by count (descending), tie-broken alphabetically. The tiebreak
258 -- keeps the max_words cutoff deterministic across processes -- the same
259 -- reason as generate-word-pages.lua's get_word_list. Without it, the
260 -- wordcloud and the word-embedding/word-page stages can disagree on which
261 -- boundary words make the cut, producing words with pages but no embedding.
262 table.sort(filtered, function(a, b)
263 if a.count ~= b.count then return a.count > b.count end
264 return a.word < b.word
265 end)
266
267 -- Limit to max_words
268 local result = {}
269 for i = 1, math.min(#filtered, CONFIG.max_words) do
270 result[i] = filtered[i]
271 end
272
273 utils.log_info(string.format("Filtered to %d words (min occurrences: %d)",
274 #result, CONFIG.min_occurrences))
275
276 return result
277end
278-- }}}
279
280-- {{{ calculate_font_sizes
281-- Issue 8-043c: Use logarithmic scaling for more gradual font size variation
282-- Word frequencies follow Zipf's law (power law), so linear scaling clusters
283-- most words at the minimum size. Log scaling spreads them more evenly.
284local function calculate_font_sizes(words)
285 if #words == 0 then return words end
286
287 -- Find min and max counts
288 local min_count = words[#words].count -- Last item (lowest count)
289 local max_count = words[1].count -- First item (highest count)
290
291 -- Calculate font size for each word using logarithmic scaling
292 for _, entry in ipairs(words) do
293 local normalized
294 if max_count == min_count then
295 normalized = 0.5 -- All same frequency
296 else
297 -- Log scaling: compresses high values, spreads low values
298 -- Add 1 to avoid log(0), shift so min_count maps to 0
299 local log_range = math.log(max_count - min_count + 1)
300 local log_value = math.log(entry.count - min_count + 1)
301 normalized = log_value / log_range
302 end
303
304 -- Map to font size range (1-7)
305 entry.font_size = math.floor(CONFIG.font_size_min +
306 normalized * (CONFIG.font_size_max - CONFIG.font_size_min) + 0.5)
307 end
308
309 return words
310end
311-- }}}
312
313-- {{{ local function generate_poem_index
314-- Issue 8-046: Generate poem index section showing all poems by category
315-- Issue 6-031: Uses poem.id (not sequential index) to respect tombstones -
316-- excluded poems leave gaps in the ID sequence, they don't shift other IDs
317-- Issue 8-043c: Simplified format - just poem IDs, multiple per line
318local function generate_poem_index(poems_data)
319 if not poems_data or not poems_data.poems then
320 return ""
321 end
322
323 -- Issue 10-036: poem_index -> chronological page map so each index entry
324 -- links to the correct paginated page (and anchor), not always page 1.
325 -- Uses the chronological-page generator's OWN mapping (shared) so the page
326 -- numbers match exactly. The page SIZE comes from resolve_chrono_per_page()
327 -- (the build's --chrono-per-page, else config) -- guessing it wrong is what
328 -- sent links to the wrong page; an absent size is a hard error, not a guess.
329 local chrono_page_map = {}
330 do
331 local per_page = resolve_chrono_per_page()
332 local mapping = flat_html.compute_chronological_mapping(poems_data, per_page)
333 for poem_index, info in pairs(mapping) do
334 chrono_page_map[poem_index] = string.format("%02d", info.page_number)
335 end
336 end
337
338 -- Group poems by category
339 local categories = {}
340 for _, poem in ipairs(poems_data.poems) do
341 local cat = poem.category or "unknown"
342 if not categories[cat] then
343 categories[cat] = {}
344 end
345 table.insert(categories[cat], poem)
346 end
347
348 -- Sort poems within each category by ID
349 for _, poems in pairs(categories) do
350 table.sort(poems, function(a, b)
351 return (a.id or 0) < (b.id or 0)
352 end)
353 end
354
355 -- Issue 8-051: Order categories by ascending poem count (smallest first)
356 -- Removes the need for a hardcoded category list — new sources auto-sort
357 local ordered_cats = {}
358 for cat, _ in pairs(categories) do
359 table.insert(ordered_cats, cat)
360 end
361 table.sort(ordered_cats, function(a, b)
362 return #categories[a] < #categories[b]
363 end)
364
365 -- Generate index HTML - simplified format with multiple IDs per line
366 -- Issue 10-055 (Feature G): the #poem-index anchor lets the source browser's
367 -- "output/" entry deep-link straight to this list, so every generated output
368 -- page is reachable from one place without the browser having to enumerate
369 -- the tens of thousands of similar/different/chronological pages itself.
370 local index_parts = {}
371 table.insert(index_parts, [[
372<hr>
373<h2 id="poem-index">Poem Index</h2>
374<p>Click any poem ID to jump to its chronological position</p>
375<table align="center"><tr><td>
376<pre>
377]])
378
379 local IDS_PER_LINE = 10 -- Show 10 poem IDs per line
380
381 for _, cat in ipairs(ordered_cats) do
382 local poems = categories[cat]
383 table.insert(index_parts, string.format(
384 "\n<b>%s</b> (%d poems)\n",
385 cat:upper(), #poems
386 ))
387
388 -- Build lines of poem IDs
389 local line_ids = {}
390 for i, poem in ipairs(poems) do
391 -- Issue 10-036: anchor + page target the poem's true chronological
392 -- position. Chronological pages emit <span id="poem-<poem_index>">
393 -- and are paginated, so link to chronological/<NN>.html#poem-<index>
394 -- instead of index.html (a redirect that drops the anchor -> page 1)
395 -- and instead of the old "poem-CATEGORY-ID" anchor that matched
396 -- nothing.
397 local pidx = poem.poem_index or 0
398 local anchor_id = string.format("poem-%d", pidx)
399 local page_str = chrono_page_map[pidx] or "01"
400 local id_str = tostring(poem.id or 0)
401
402 -- Keep column alignment with leading spaces, but place them OUTSIDE
403 -- the <a> so the clickable target is just the number (e.g. "46"),
404 -- not " 46". The spaces are monospaced inside <pre>, so the
405 -- columns still line up.
406 local pad = string.rep(" ", math.max(0, 4 - #id_str))
407 local link = pad .. string.format(
408 '<a href="chronological/%s.html#%s">%s</a>',
409 page_str, anchor_id, id_str)
410 table.insert(line_ids, link)
411
412 -- Output line when we reach IDS_PER_LINE or end of poems
413 if #line_ids >= IDS_PER_LINE or i == #poems then
414 table.insert(index_parts, " " .. table.concat(line_ids, " ") .. "\n")
415 line_ids = {}
416 end
417 end
418 end
419
420 table.insert(index_parts, [[
421</pre>
422</td></tr></table>
423]])
424
425 return table.concat(index_parts)
426end
427-- }}}
428
429-- {{{ archive_wordcloud()
430-- Keep a permanent, timestamped copy of every word cloud we generate. The live
431-- page (output/wordcloud.html) is overwritten on every build, so without this the
432-- history of how the cloud changes over time -- which words rise and fall, how the
433-- "all words" cloud differs from the default -- would be lost. The archive lives
434-- OUTSIDE output/ (under archive/wordclouds/) on purpose: it is a local record, not
435-- something deployed to the site. A failed archive write is a hard error, not a
436-- shrug -- if we meant to keep a copy and couldn't, we want to know.
437local function archive_wordcloud(html, word_count)
438 local archive_dir = DIR .. "/archive/wordclouds"
439 utils.ensure_directory(archive_dir)
440 -- Timestamp + word count in the name: successive builds accumulate instead of
441 -- overwriting, and the count tells "all words" (7082) from a default (200) at a
442 -- glance. No spaces, so the plain mkdir/io paths handle it.
443 local stamp = os.date("%Y-%m-%d_%H-%M-%S")
444 local archive_file = string.format("%s/wordcloud-%s-%dwords.html",
445 archive_dir, stamp, word_count)
446 if not utils.write_file(archive_file, html) then
447 error("Failed to archive word cloud to: " .. archive_file)
448 end
449 utils.log_info("Archived word cloud: " .. archive_file)
450end
451-- }}}
452
453-- {{{ generate_wordcloud_html
454local function generate_wordcloud_html(words, output_dir, poems_data)
455 -- Issue 16-010: Load word colors and color configuration for colorized display
456 local word_colors = load_word_colors()
457 local color_config = unified_config.colors or {
458 red = "#FF6B6B",
459 orange = "#FFA94D",
460 yellow = "#FFE066",
461 green = "#69DB7C",
462 blue = "#74C0FC",
463 purple = "#DA77F2",
464 gray = "#868E96"
465 }
466
467 -- Shuffle words for visual variety (not just sorted by size)
468 local shuffled = {}
469 for i, w in ipairs(words) do shuffled[i] = w end
470
471 -- Fisher-Yates shuffle. Issue 10-058: the RNG was seeded ONCE at startup from
472 -- the resolved master seed (MASTER_SEED) -- do NOT re-seed here. Re-seeding per
473 -- call from os.time() (the old behaviour) was non-reproducible AND, at 1-second
474 -- clock resolution, gave two same-second builds the identical "random" order.
475 for i = #shuffled, 2, -1 do
476 local j = math.random(i)
477 shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
478 end
479
480 -- Generate word spans with links to similar pages
481 -- Issue 8-043: Each word links to wordcloud/{word}.html showing poems similar to that word
482 -- Issue 16-010: Words are now colored by their semantic color
483 local word_html = {}
484 for _, entry in ipairs(shuffled) do
485 -- Sanitize word for URL (lowercase, no special chars)
486 local safe_word = entry.word:lower():gsub("[^%w]", "")
487
488 -- Significance threshold: only the larger words carry their semantic
489 -- color. font_size >= 5 is the same cutoff that bolds a word (~the top
490 -- 65% of the 1-7 size range), so emphasis and color move together.
491 -- Smaller words render in neutral gray, making color a signal of
492 -- significance rather than visual noise on every word.
493 local is_significant = entry.font_size >= 5
494 local bold_open, bold_close = "", ""
495 local hex_color = "#868E96" -- neutral gray for the long tail
496 if is_significant then
497 bold_open, bold_close = "<b>", "</b>"
498 -- Issue 16-010: Look up this word's semantic color. Large words never
499 -- render gray (gray belongs to the de-emphasised small words), so we take
500 -- the strongest NON-gray color from the word's full color ranking.
501 local semantic_color = top_nongray_color(word_colors[safe_word]) or "gray"
502 hex_color = color_config[semantic_color] or "#868E96"
503 end
504
505 -- Each word links to its similarity page, colored by semantic meaning
506 table.insert(word_html, string.format(
507 '<a href="wordcloud/%s.html"><font size="%d" color="%s">%s%s%s</font></a>',
508 safe_word, entry.font_size, hex_color, bold_open, entry.word, bold_close
509 ))
510 end
511
512 -- Generate poem index section (Issue 8-046)
513 local poem_index = generate_poem_index(poems_data)
514
515 -- Generate HTML page
516 -- Issue 16-010: Added font style for Hack Nerd Font font-stack
517 -- Same centering CSS as the poem pages: the <pre> poem-ID list centers as an
518 -- inline-block (text stays left) so it sits on the page centerline.
519 local font_style = [[<style>body, pre { font-family: 'Hack Nerd Font', 'Hack', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', 'Consolas', 'Monaco', 'Liberation Mono', 'Courier New', monospace; }
520td { text-align: center; } pre { display: inline-block; text-align: left; margin: 0 auto; } img, video, audio { margin-left: auto; margin-right: auto; }</style>]]
521 local html = string.format([[<!DOCTYPE html>
522<!-- Issue 10-058: word order shuffled with master seed %d. Re-run with
523 --seed %d (or set randomization.seed in config.lua) to reproduce this exact
524 word cloud. The canonical record is output/generation-metadata.json. -->
525<html>
526<head>
527<meta charset="UTF-8">
528<title>Menu - Poetry Collection</title>
529%s</head>
530<body bgcolor="#000000" text="#FFFFFF" link="#6699FF" vlink="#9966FF">]], MASTER_SEED, MASTER_SEED, font_style) .. string.format([[
531
532<center>
533<h1>Menu</h1>
534<p><a href="explore.html">Explore</a> │ <a href="chronological/01.html">Chronological</a> │ <a href="gallery/index.html">Gallery</a></p>
535<hr>
536<h2>Word Cloud</h2>
537<p>Words sized by frequency across %d poems (click to explore similar poems)</p>
538<p>
539%s
540</p>
541<p><i>%d unique words shown (minimum %d occurrences)</i></p>
542%s
543</center>
544
545</body>
546</html>]], #words > 0 and words[1].total_poems or 0,
547 table.concat(word_html, " &nbsp; "),
548 #shuffled, CONFIG.min_occurrences,
549 poem_index)
550
551 -- Write file
552 local output_file = output_dir .. "/" .. CONFIG.output_file
553 local success = utils.write_file(output_file, html)
554
555 if success then
556 utils.log_info("Generated: " .. output_file)
557 -- Keep a dated copy of this build's cloud in archive/wordclouds/.
558 archive_wordcloud(html, #words)
559 return output_file
560 else
561 utils.log_error("Failed to write: " .. output_file)
562 return nil
563 end
564end
565-- }}}
566
567-- {{{ function M.generate_wordcloud
568function M.generate_wordcloud(poems_data, output_dir)
569 -- Load stop words
570 local stop_words = load_stop_words()
571
572 -- Extract words from poems
573 local poems = poems_data.poems or {}
574 local word_counts = extract_words_from_poems(poems, stop_words)
575
576 -- Filter and sort
577 local words = filter_and_sort_words(word_counts)
578
579 -- Calculate font sizes
580 words = calculate_font_sizes(words)
581
582 -- Add metadata for HTML generation
583 if #words > 0 then
584 words[1].total_poems = #poems
585 end
586
587 -- Generate HTML (pass poems_data for poem index - Issue 8-046)
588 return generate_wordcloud_html(words, output_dir, poems_data)
589end
590-- }}}
591
592-- {{{ function M.main
593function M.main()
594 -- Load poems
595 local poems_file = utils.asset_path("poems.json")
596 local poems_data = utils.read_json_file(poems_file)
597
598 if not poems_data then
599 utils.log_error("Could not load poems.json")
600 return nil
601 end
602
603 local output_dir = DIR .. "/output"
604 return M.generate_wordcloud(poems_data, output_dir)
605end
606-- }}}
607
608-- {{{ Command line execution
609if arg and #arg >= 0 and debug.getinfo(3) == nil then
610 if arg[1] == "--help" or arg[1] == "-h" then
611 print("Usage: luajit src/wordcloud-generator.lua [DIR] [--all] [--words N] [--chrono-per-page N] [--seed N]")
612 print("")
613 print("Generates a word cloud HTML page from the poetry collection.")
614 print("Words are sized by frequency, with stop words filtered out.")
615 print("")
616 print("Options:")
617 print(" DIR Project directory (default: /mnt/mtwo/programming/ai-stuff/neocities-modernization)")
618 print(" --all Include all words (no max_words limit)")
619 print(" --chrono-per-page N Chronological page size; MUST match the value the")
620 print(" chronological pages were built with, or poem links")
621 print(" point at the wrong page. Defaults to the config value.")
622 print(" --words N Set maximum words to display (default: 200 from config)")
623 print(" --seed N Master seed for the word shuffle (Issue 10-058).")
624 print(" Same seed => identical word order. Normally passed")
625 print(" by run.sh; if omitted a seed is auto-generated and")
626 print(" logged to stderr.")
627 print(" --help Show this help message")
628 os.exit(0)
629 end
630
631 M.main()
632end
633-- }}}
634
635return M
636