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