scripts/convert-urls

#!/usr/bin/env luajit
-- convert-urls: Converts HTML file URLs between local file:// paths and production paths
--
-- This script recursively finds all HTML files in the output directory and replaces
-- URL patterns to switch between local testing mode and production deployment mode.
-- It is idempotent - running the same conversion multiple times has no additional effect.
--
-- Usage:
-- ./scripts/convert-urls --to-production Convert file:// URLs to /similar-different/
-- ./scripts/convert-urls --to-local Convert /similar-different/ URLs to file://
-- ./scripts/convert-urls --dry-run -p Preview production conversion without changes
-- ./scripts/convert-urls --help Show usage information

-- {{{ Configuration
local DIR = "/mnt/mtwo/programming/ai-stuff/neocities-modernization"

-- URL patterns for conversion (multiple patterns supported)
-- Local paths use /home/ritz/... which is a symlink to /mnt/mtwo/...
local URL_PATTERNS = {
-- Pattern 1: Output directory links (similar/different/chronological pages)
{
local_path = "file:///home/ritz/programming/ai-stuff/neocities-modernization/output",
production_path = "/similar-different"
},
-- Pattern 2: Media attachments (images from ActivityPub extraction)
-- Issue 8-048: Updated for flat media directory structure (output/media/)
-- Old nested structure: input/media_attachments/files/112/.../abc.png
-- New flat structure: output/media/abc.png
{
local_path = "file:///home/ritz/programming/ai-stuff/neocities-modernization/output/media",
production_path = "/similar-different/media"
}
}

-- Legacy single pattern for backwards compatibility
local LOCAL_BASE = URL_PATTERNS[1].local_path
local PRODUCTION_BASE = URL_PATTERNS[1].production_path

-- Output directory containing HTML files
local OUTPUT_DIR = DIR .. "/output"

-- Directories (relative to OUTPUT_DIR) whose HTML must NOT be rewritten.
-- Why this exists: the self-hosted source browser (issue 10-052) renders the
-- project's own code into output/source/ as syntax-highlighted, line-numbered
-- pages. That code legitimately CONTAINS the base-path strings this script
-- rewrites (e.g. a Lua line that reads
-- local base_path = "file:///.../output"
-- or "/similar-different"). Those occurrences are code-as-DATA being displayed,
-- not deployable links. This converter does a context-blind plain-text
-- substring replace, so without this exclusion it silently corrupts the
-- displayed source — making the viewer lie about what the code actually says.
-- The source browser's own navigation is entirely RELATIVE, so it never needs
-- conversion: skipping the whole subtree is both safe and correct.
local EXCLUDE_DIRS = {
"source"
}
-- }}}

-- {{{ parse_arguments
local function parse_arguments(args)
local options = {
mode = nil, -- "to-production" or "to-local"
dry_run = false,
verbose = false,
help = false
}

local i = 1
while i <= #args do
local arg = args[i]

if arg == "--to-production" or arg == "-p" then
options.mode = "to-production"
elseif arg == "--to-local" or arg == "-l" then
options.mode = "to-local"
elseif arg == "--dry-run" or arg == "-d" then
options.dry_run = true
elseif arg == "--verbose" or arg == "-v" then
options.verbose = true
elseif arg == "--help" or arg == "-h" then
options.help = true
elseif arg == "--dir" then
i = i + 1
if args[i] then
OUTPUT_DIR = args[i]
end
else
io.stderr:write("Unknown argument: " .. arg .. "\n")
options.help = true
end

i = i + 1
end

return options
end
-- }}}

-- {{{ print_usage
local function print_usage()
print([[
convert-urls: Convert HTML URLs between local and production paths

Usage:
./scripts/convert-urls [OPTIONS]

Options:
-p, --to-production Convert local file:// URLs to production paths
-l, --to-local Convert production paths back to local file:// URLs
-d, --dry-run Preview changes without modifying files
-v, --verbose Show detailed progress for each file
--dir PATH Override output directory (default: output/)
-h, --help Show this help message

Examples:
# Convert to production before deploying to neocities
./scripts/convert-urls --to-production

# Preview what would be changed
./scripts/convert-urls --to-production --dry-run

# Convert back to local for browser testing
./scripts/convert-urls --to-local

URL Patterns:
Local: file:///home/ritz/programming/ai-stuff/neocities-modernization/output
Production: /similar-different

The script is idempotent - running the same conversion twice has no effect.
]])
end
-- }}}

-- {{{ build_prune_expression
-- Turn EXCLUDE_DIRS into a find(1) prune clause so excluded subtrees are never
-- descended into. Producing an empty string when nothing is excluded keeps the
-- command identical to the original behavior for callers with no exclusions.
local function build_prune_expression(exclude_dirs)
if not exclude_dirs or #exclude_dirs == 0 then
return ""
end

-- Match each excluded directory by name anywhere under the root, e.g.
-- -type d \( -name source \) -prune -o
-- The trailing "-o" hands control to the file-matching clause that follows.
local names = {}
for _, name in ipairs(exclude_dirs) do
table.insert(names, string.format('-name "%s"', name))
end

return string.format('-type d \\( %s \\) -prune -o ',
table.concat(names, " -o "))
end
-- }}}

-- {{{ find_html_files
local function find_html_files(directory)
-- Recursively locate all HTML files, pruning excluded subtrees first.
-- The prune clause runs BEFORE the file test so find never even walks into,
-- e.g., output/source/ — cheaper than listing then filtering, and it means
-- the reported "files scanned" count reflects only convertible files.
-- (stderr is left attached so permission/IO errors surface rather than hide.)
local prune = build_prune_expression(EXCLUDE_DIRS)
local cmd = string.format('find "%s" %s-type f -name "*.html" -print',
directory, prune)
local handle = io.popen(cmd)
if not handle then
return {}
end

local files = {}
for line in handle:lines() do
table.insert(files, line)
end
handle:close()

return files
end
-- }}}

-- {{{ read_file
local function read_file(path)
local file = io.open(path, "rb")
if not file then
return nil, "Could not open file: " .. path
end

local content = file:read("*all")
file:close()

return content
end
-- }}}

-- {{{ write_file
local function write_file(path, content)
local file = io.open(path, "wb")
if not file then
return false, "Could not write file: " .. path
end

file:write(content)
file:close()

return true
end
-- }}}

-- {{{ escape_pattern
-- Escape special pattern characters for Lua string matching
local function escape_pattern(str)
-- Escape all Lua pattern magic characters: ^$()%.[]*+-?
return str:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
end
-- }}}

-- {{{ count_occurrences
local function count_occurrences(str, pattern)
local count = 0
local start = 1
while true do
local pos = str:find(pattern, start, true) -- plain text search
if not pos then break end
count = count + 1
start = pos + 1
end
return count
end
-- }}}

-- {{{ convert_file
local function convert_file(path, from_pattern, to_pattern, dry_run, verbose)
local content, err = read_file(path)
if not content then
return 0, err
end

-- Count occurrences before replacement
local count = count_occurrences(content, from_pattern)

if count == 0 then
return 0, nil -- No changes needed
end

-- Perform replacement using plain text (gsub with escaped pattern)
local escaped_from = escape_pattern(from_pattern)
local new_content = content:gsub(escaped_from, to_pattern)

if not dry_run then
local success, write_err = write_file(path, new_content)
if not success then
return 0, write_err
end
end

if verbose then
local action = dry_run and "Would replace" or "Replaced"
print(string.format(" %s %d occurrence(s) in %s", action, count, path))
end

return count, nil
end
-- }}}

-- {{{ main
local function main()
local options = parse_arguments(arg)

if options.help then
print_usage()
os.exit(0)
end

if not options.mode then
io.stderr:write("Error: Must specify --to-production or --to-local\n")
io.stderr:write("Use --help for usage information\n")
os.exit(1)
end

-- Determine conversion direction
local direction_desc
local patterns_to_apply = {}

if options.mode == "to-production" then
direction_desc = "local → production"
for _, pattern in ipairs(URL_PATTERNS) do
table.insert(patterns_to_apply, {
from = pattern.local_path,
to = pattern.production_path
})
end
else
direction_desc = "production → local"
for _, pattern in ipairs(URL_PATTERNS) do
table.insert(patterns_to_apply, {
from = pattern.production_path,
to = pattern.local_path
})
end
end

-- Print header
print("=" .. string.rep("=", 60))
print("URL Converter: " .. direction_desc)
print("=" .. string.rep("=", 60))
print("")

if options.dry_run then
print(" DRY RUN MODE - No files will be modified ")
print("")
end

print("URL Patterns (" .. #patterns_to_apply .. "):")
for i, p in ipairs(patterns_to_apply) do
print(string.format(" [%d] %s", i, p.from))
print(string.format(" → %s", p.to))
end
print("")
print("Dir: " .. OUTPUT_DIR)
if #EXCLUDE_DIRS > 0 then
-- Make the skip explicit: a silent exclusion reads as "converted
-- everything" when it did not (the source browser is left untouched
-- on purpose — it displays code, not links).
print("Skip: " .. table.concat(EXCLUDE_DIRS, ", ")
.. " (source-as-data; not deployable links)")
end
print("")

-- Find all HTML files
print("Scanning for HTML files...")
local files = find_html_files(OUTPUT_DIR)

if #files == 0 then
print("No HTML files found in " .. OUTPUT_DIR)
os.exit(0)
end

print(string.format("Found %d HTML files", #files))
print("")

-- Process each file with all patterns
local total_replacements = 0
local files_modified = 0
local errors = {}

for _, file_path in ipairs(files) do
local file_total = 0
local file_had_error = false

-- Apply each pattern to this file
for _, pattern in ipairs(patterns_to_apply) do
local count, err = convert_file(file_path, pattern.from, pattern.to, options.dry_run, options.verbose)

if err then
table.insert(errors, { path = file_path, error = err })
file_had_error = true
break -- Stop processing this file on error
else
file_total = file_total + count
end
end

if not file_had_error and file_total > 0 then
total_replacements = total_replacements + file_total
files_modified = files_modified + 1
end
end

-- Print summary
print("")
print("-" .. string.rep("-", 60))
print("Summary:")
print("-" .. string.rep("-", 60))

local action = options.dry_run and "Would modify" or "Modified"
print(string.format(" Files scanned: %d", #files))
print(string.format(" %s: %d files", action, files_modified))
print(string.format(" URLs %s: %d", options.dry_run and "to replace" or "replaced", total_replacements))

if #errors > 0 then
print("")
print("Errors:")
for _, e in ipairs(errors) do
print(string.format(" %s: %s", e.path, e.error))
end
end

print("")

if options.dry_run and total_replacements > 0 then
print("Run without --dry-run to apply changes.")
elseif total_replacements == 0 then
print("No URLs needed conversion (already in target format or no matches found).")
else
print("Conversion complete!")
end
end
-- }}}

main()