-- sitemap Soupault Plugin (post-build hook) -- SPDX-FileCopyrightText: 2026 toastal -- SPDX-License-Identifier: MPL-2.0 -- -- Generates a sitemap.xml by walking the build output. A filesystem walk is -- used rather than site_index because Soupault omits index pages (/, /section/) -- from the index, which are precisely the URLs a sitemap must include. -- -- Intended for use as: -- hooks.post-build.file = "hook/sitemap.lua" Plugin.require_version("5.0.0") custom_options = soupault_config["custom_options"] if Value.is_nil(custom_options) or not Table.has_key(custom_options, "site_url") then Plugin.fail([[custom_options["site_url"] option is required for sitemap generation]]) end site_url = custom_options["site_url"] output_file = Table.get_key_default(config, "output_file", "sitemap.xml") exclude_path_regexes = Table.get_key_default(config, "exclude_path_regexes", {}) -- NOTE: this Lua dialect has no true/false literals (they evaluate to nil), -- & the TOML→Lua bridge drops explicit false values entirely, so an absent -- clean_urls is indistinguishable from clean_urls = false. Fall back to 1, -- which matches Soupault core’s own default (clean_urls = true). clean_urls = soupault_config["settings"]["clean_urls"] if Value.is_nil(clean_urls) then clean_urls = 1 end function is_excluded(web_path) local idx = 1 while exclude_path_regexes[idx] do if Regex.match(web_path, exclude_path_regexes[idx]) then return 1 end idx = idx + 1 end return nil end function str_compare(a, b) if a < b then return -1 end if a > b then return 1 end return 0 end -- Explicit stack (each item is a table with fs/web paths) to avoid recursion -- (no TCO in Lua 2.5). stack = {} stack[1] = { fs = build_dir, web = "/" } top = 1 urls = {} sitemap_path = Sys.join_path(build_dir, output_file) while top >= 1 do cur = stack[top] top = top - 1 entries = Sys.list_dir(cur.fs) local idx = 1 while entries[idx] do -- NOTE: Sys.list_dir returns full paths (meaning "build/about"), not -- basenames, so use entries directly as filesystem paths. local fpath = entries[idx] local name = Sys.basename(fpath) if not String.starts_with(name, ".") and fpath ~= sitemap_path then if Sys.is_dir(fpath) then local web = nil if cur.web == "/" then web = "/" .. name .. "/" else web = cur.web .. name .. "/" end top = top + 1 stack[top] = { fs = fpath, web = web } elseif clean_urls and name == "index.html" then if not is_excluded(cur.web) then urls[Table.length(urls) + 1] = cur.web end elseif not clean_urls and Sys.has_extension(name, "html") then local web = nil if cur.web == "/" then web = "/" .. name else web = cur.web .. name end if not is_excluded(web) then urls[Table.length(urls) + 1] = web end end end idx = idx + 1 end end if Table.is_empty(urls) then Plugin.fail("Sitemap generation found no pages in build output.") end Table.sort(str_compare, urls) default_template = [[ {%- for url in urls %} {{ url | escape }} {%- endfor -%} ]] template = default_template if not Value.is_nil(config["template_file"]) then template = Sys.read_file(config["template_file"]) end function make_loc(web_path) return site_url .. web_path end locs = Table.copy(urls) Table.apply_to_values(make_loc, locs) data = {} data["urls"] = locs sitemap = String.render_template(template, data) Sys.write_file(Sys.join_path(build_dir, output_file), sitemap) Log.info(format("Wrote sitemap with %d URLs to %s", Table.length(urls), Sys.join_path(build_dir, output_file)))