using Xml; using Html; using Astralis; using Invercargill; using Invercargill.DataStructures; namespace Statum.Tools { /** * `statum-mkpstm` — the build-time Statum pre-rendering pipeline. * * Turns an app HTML page (plus its templates/fragments) into a Vala * {@link Statum.StatumPage} subclass: a static, precompressed document whose * route comes from its ``. Composition (templates, fragments, * `pstm-res` rewriting) is applied, then the result is precompressed with * identity/gzip/zstd/br. */ public class Mkpstm : Object { private static string? output_file = null; private static string? namespace_name = null; private static string? class_name_override = null; private static string? route_override = null; private static List template_dirs = new List(); private Dictionary templates_by_name = new Dictionary(); private Dictionary fragments_by_name = new Dictionary(); /** * The Statum client ships as four modules with a strict load order * (core first: every later module registers into the seams core * installs). A legacy single-file `pstm-res-src="statum.js"` reference * expands at build time into exactly these four tags. */ private const string[] CLIENT_MODULE_ORDER = { "statum-core.js", "statum-document.js", "statum-markdown.js", "statum-forms.js" }; private const OptionEntry[] options = { { "output", 'o', 0, OptionArg.FILENAME, ref output_file, "Output Vala file name", "FILE" }, { "ns", '\0', 0, OptionArg.STRING, ref namespace_name, "Namespace for the generated class", "NAMESPACE" }, { "name", 'n', 0, OptionArg.STRING, ref class_name_override, "Generated class name (default: Page)", "NAME" }, { "route", 'r', 0, OptionArg.STRING, ref route_override, "Page route (default: from )", "ROUTE" }, { "template-dir", 't', 0, OptionArg.FILENAME_ARRAY, ref template_dirs, "Directory to search for templates/fragments (repeatable)", "DIR" }, { null } }; public static int main(string[] args) { try { var opt = new OptionContext("PAGE_FILE - Generate a Statum StatumPage"); opt.add_main_entries(options, null); opt.parse(ref args); } catch (OptionError e) { stderr.printf("Error: %s\n", e.message); return 1; } string? page_file = null; if (args.length > 1) { page_file = args[1]; } if (page_file == null) { stderr.printf("Error: No page file specified.\n"); return 1; } try { var tool = new Mkpstm(); var generated = tool.run((!)page_file); var out_path = tool.output_file ?? (make_pascal_case((!)page_file) + "Page.vala"); write_file(out_path, generated); stdout.printf("Generated: %s\n", out_path); return 0; } catch (GLib.Error e) { stderr.printf("Error: %s\n", e.message); return 1; } } public string run(string page_file) throws GLib.Error { if (template_dirs.length() == 0) { var parent = File.new_for_path(page_file).get_parent(); template_dirs.append(parent != null ? ((!)parent).get_path() : "."); } load_templates_and_fragments(page_file); var doc = parse_html_file(page_file); validate_document(doc, page_file); validate_head_directives(doc, page_file); // Capture the page's routes before composition (the // directive is dropped while merging templates). var routes = read_routes(doc); doc = compose_templates(doc); inline_fragments(doc); expand_legacy_client(doc, page_file); rewrite_resources(doc); var variant = build_variant(doc); var class_name = class_name_override ?? (make_pascal_case(page_file) + "Page"); var route = route_override ?? (routes.length > 0 ? routes[0] : ""); return emit(class_name, route, variant); } // ------------------------------------------------------------------ // Template/fragment loading // ------------------------------------------------------------------ private void load_templates_and_fragments(string page_file) throws GLib.Error { foreach (var dir in template_dirs) { var dir_file = File.new_for_path(dir); if (!dir_file.query_exists()) { continue; } var enumerator = dir_file.enumerate_children("standard::name", 0); FileInfo info; while ((info = enumerator.next_file()) != null) { var name = info.get_name(); if (!name.has_suffix(".html") || name.has_prefix(".")) { continue; } var path = Path.build_filename(dir, name); if (path == page_file) { continue; } var tdoc = parse_html_file(path); var source = Path.build_filename(dir, name); validate_document(tdoc, source); validate_head_directives(tdoc, source); var tname = read_pstm_name(tdoc); if (tname == null) { continue; } if (find_first_element(tdoc->get_root_element(), "pstm-content") != null) { templates_by_name.set((!)tname, tdoc); } else { fragments_by_name.set((!)tname, tdoc); } } } } // ------------------------------------------------------------------ // Composition // ------------------------------------------------------------------ private Xml.Doc* compose_templates(Xml.Doc* doc) throws GLib.Error { Xml.Doc* current_doc = doc; string merged_title = read_directive_in_doc(current_doc, "pstm-title") ?? ""; // Guard against cyclic template chains (A templates B templates A). var visited = new HashSet(); while (true) { var parent_name = read_directive_in_doc(current_doc, "pstm-template"); if (parent_name == null || parent_name.length == 0) { break; } if (visited.contains((!)parent_name)) { break; } visited.add((!)parent_name); Xml.Doc* parent_doc; if (!templates_by_name.try_get((!)parent_name, out parent_doc)) { throw new IOError.FAILED(@"Unknown template \"$((!)parent_name)\""); } // Merge this document's into the parent's (so // pages, templates and nested components can all declare head // tags), then splice its into the parent's content outlet. merge_head(current_doc, parent_doc); splice_body(current_doc, parent_doc); var parent_title = read_directive_in_doc(parent_doc, "pstm-title"); if (parent_title != null && parent_title.length > 0) { merged_title = merged_title + (!)parent_title; } current_doc = parent_doc; } apply_title(current_doc, merged_title); return current_doc; } /** * Copies the children of `child_doc`'s into `parent_doc`'s , * skipping the consumed structural directives (pstm-template/name/title/ * uri) which are page-level metadata and must not be carried up. */ private void merge_head(Xml.Doc* child_doc, Xml.Doc* parent_doc) { var child_head = find_head(child_doc); var parent_head = find_head(parent_doc); if (child_head == null || parent_head == null) { return; } for (var child = child_head->children; child != null; child = child->next) { if (!is_element(child)) { continue; } var name = (string) child->name; if (name == "pstm-template" || name == "pstm-name" || name == "pstm-title" || name == "pstm-uri") { continue; } var copied = child->doc_copy(parent_doc, 1); if (copied != null) { parent_head->add_child(copied); } } } /** * Splices `child_doc`'s into `parent_doc`'s outlet * (or appends to the parent's when there is no outlet). When the * child carries `pstm-materialise-as`, it is wrapped in an element * of that tag (carrying the body's non-pstm attributes); otherwise its * children are spliced directly. */ private void splice_body(Xml.Doc* child_doc, Xml.Doc* parent_doc) { var child_body = find_body(child_doc); var parent_body = find_body(parent_doc); if (child_body == null || parent_body == null) { return; } var outlet = find_first_element(parent_body, "pstm-content"); var materialise_as = child_body->get_prop("pstm-materialise-as"); if (materialise_as != null) { var wrapper = parent_body->new_child(null, (!)materialise_as, null); wrapper->unlink(); copy_non_pstm_attrs(child_body, wrapper); for (var child = child_body->children; child != null; child = child->next) { if (is_structural_directive(child)) { continue; } var copied = child->doc_copy(parent_doc, 1); if (copied != null) { wrapper->add_child(copied); } } if (outlet != null) { outlet->add_prev_sibling(wrapper); } else { parent_body->add_child(wrapper); } } else { for (var child = child_body->children; child != null; child = child->next) { if (is_structural_directive(child)) { continue; } var copied = child->doc_copy(parent_doc, 1); if (copied == null) { continue; } if (outlet != null) { outlet->add_prev_sibling(copied); } else { parent_body->add_child(copied); } } } if (outlet != null) { outlet->unlink(); } } /** Copies an element's non-`pstm-*` attributes onto another element. */ private static void copy_non_pstm_attrs(Xml.Node* src, Xml.Node* dst) { for (var attr = src->properties; attr != null; attr = attr->next) { var attr_name = (string) attr->name; if (attr_name.has_prefix("pstm-")) { continue; } var value = src->get_prop(attr_name); if (value != null) { dst->set_prop(attr_name, (!)value); } } } private static string? read_directive_in_doc(Xml.Doc* doc, string name) { var root = doc->get_root_element(); if (root == null) { return null; } return read_directive_text(root, name); } private static bool is_structural_directive(Xml.Node* node) { if (!is_element(node)) { return false; } var name = (string) node->name; return name == "pstm-template" || name == "pstm-name" || name == "pstm-title" || name == "pstm-uri" || name == "pstm-content"; } /** Fragment-body metadata elements skipped when inlining (e.g. pstm-input). */ private static bool is_fragment_metadata(Xml.Node* node) { if (!is_element(node)) { return false; } var name = (string) node->name; return name == "pstm-input" || name.has_prefix("pstm-"); } private void inline_fragments(Xml.Doc* doc) { bool changed = true; while (changed) { changed = false; var includes = find_all_elements(doc, "pstm-fragment"); foreach (var include in includes) { if (inline_one_fragment(doc, include)) { changed = true; } } } } private bool inline_one_fragment(Xml.Doc* doc, Xml.Node* include) { var name = include->get_prop("name"); if (name == null) { return false; } Xml.Doc* fragment_doc; if (!fragments_by_name.try_get((!)name, out fragment_doc)) { return false; } var fragment_body = find_body(fragment_doc); if (fragment_body == null) { return false; } // Merge the fragment's into the document's . merge_head(fragment_doc, doc); var inputs = collect_input_names(fragment_body); var include_materialise = include->get_prop("pstm-materialise-as"); var fragment_materialise = fragment_body->get_prop("pstm-materialise-as"); var materialise_as = include_materialise ?? fragment_materialise; bool wrap = (materialise_as != null) || inputs.length > 0; string tag = materialise_as ?? "div"; if (wrap) { var parent = include->parent; if (parent == null) { return false; } var wrapper = parent->new_child(null, tag, null); wrapper->unlink(); include->add_prev_sibling(wrapper); // Carry the fragment 's attributes when it supplied the tag. if (include_materialise == null && fragment_materialise != null) { copy_non_pstm_attrs(fragment_body, wrapper); } for (var child = fragment_body->children; child != null; child = child->next) { if (is_fragment_metadata(child)) { continue; } var copied = child->doc_copy(doc, 1); if (copied != null) { wrapper->add_child(copied); } } foreach (var input_name in inputs) { var expr = include->get_prop(input_name); if (expr != null) { wrapper->set_prop(@"stm-def-$input_name-as", (!)expr); } } } else { for (var child = fragment_body->children; child != null; child = child->next) { if (is_fragment_metadata(child)) { continue; } var copied = child->doc_copy(doc, 1); if (copied != null) { include->add_prev_sibling(copied); } } } include->unlink(); return true; } private string[] collect_input_names(Xml.Node* fragment_body) { var names = new string[0]; for (var n = fragment_body->children; n != null; n = n->next) { if (is_element(n) && (string) n->name == "pstm-input") { var input_name = n->get_prop("name"); if (input_name != null) { names += (!)input_name; } } } return names; } private void rewrite_resources(Xml.Doc* doc) { var root = doc->get_root_element(); if (root != null) { rewrite_resources_in(root); } } /** * Expands a legacy single-file client reference into the four ordered * module tags. * * `