statum-mkpstm.vala 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
  1. using Xml;
  2. using Html;
  3. using Astralis;
  4. using Invercargill;
  5. using Invercargill.DataStructures;
  6. namespace Statum.Tools {
  7. /**
  8. * `statum-mkpstm` — the build-time Statum pre-rendering pipeline.
  9. *
  10. * Turns an app HTML page (plus its templates/fragments) into a Vala
  11. * {@link Statum.StatumPage} subclass carrying precompressed variant byte
  12. * arrays and a compiled {@link Statum.StatumPage.select_variant} that
  13. * evaluates `pstm-guard`s and the `pstm-if` matrix against the request's held
  14. * slots using the Invercargill runtime expression evaluator.
  15. *
  16. * See `phase-1-plan.md` Subsystem B for the supported `pstm-*` directives.
  17. */
  18. public class Mkpstm : Object {
  19. private static string? output_file = null;
  20. private static string? namespace_name = null;
  21. private static string? class_name_override = null;
  22. private static string? route_override = null;
  23. private static List<string> template_dirs = new List<string>();
  24. private Dictionary<string, Xml.Doc*> templates_by_name = new Dictionary<string, Xml.Doc*>();
  25. private Dictionary<string, Xml.Doc*> fragments_by_name = new Dictionary<string, Xml.Doc*>();
  26. private const OptionEntry[] options = {
  27. { "output", 'o', 0, OptionArg.FILENAME, ref output_file, "Output Vala file name", "FILE" },
  28. { "ns", '\0', 0, OptionArg.STRING, ref namespace_name, "Namespace for the generated class", "NAMESPACE" },
  29. { "name", 'n', 0, OptionArg.STRING, ref class_name_override, "Generated class name (default: <InputName>Page)", "NAME" },
  30. { "route", 'r', 0, OptionArg.STRING, ref route_override, "Page route (default: from <pstm-uri>)", "ROUTE" },
  31. { "template-dir", 't', 0, OptionArg.FILENAME_ARRAY, ref template_dirs, "Directory to search for templates/fragments (repeatable)", "DIR" },
  32. { null }
  33. };
  34. public static int main(string[] args) {
  35. try {
  36. var opt = new OptionContext("PAGE_FILE - Generate a Statum StatumPage");
  37. opt.add_main_entries(options, null);
  38. opt.parse(ref args);
  39. } catch (OptionError e) {
  40. stderr.printf("Error: %s\n", e.message);
  41. return 1;
  42. }
  43. string? page_file = null;
  44. if (args.length > 1) {
  45. page_file = args[1];
  46. }
  47. if (page_file == null) {
  48. stderr.printf("Error: No page file specified.\n");
  49. return 1;
  50. }
  51. try {
  52. var tool = new Mkpstm();
  53. var generated = tool.run((!)page_file);
  54. var out_path = tool.output_file ?? (make_pascal_case((!)page_file) + "Page.vala");
  55. write_file(out_path, generated);
  56. stdout.printf("Generated: %s\n", out_path);
  57. return 0;
  58. } catch (GLib.Error e) {
  59. stderr.printf("Error: %s\n", e.message);
  60. return 1;
  61. }
  62. }
  63. public string run(string page_file) throws GLib.Error {
  64. if (template_dirs.length() == 0) {
  65. var parent = File.new_for_path(page_file).get_parent();
  66. template_dirs.append(parent != null ? ((!)parent).get_path() : ".");
  67. }
  68. load_templates_and_fragments(page_file);
  69. var doc = parse_html_file(page_file);
  70. validate_document(doc, page_file);
  71. validate_head_directives(doc, page_file);
  72. // Capture the page's routes before composition (the <pstm-uri>
  73. // directive is dropped while merging templates).
  74. var routes = read_routes(doc);
  75. doc = compose_templates(doc);
  76. inline_fragments(doc);
  77. rewrite_resources(doc);
  78. validate_no_conditionals(doc, page_file);
  79. var variant = build_variant(doc);
  80. var class_name = class_name_override ?? (make_pascal_case(page_file) + "Page");
  81. var route = route_override ?? (routes.length > 0 ? routes[0] : "/");
  82. return emit(class_name, route, variant);
  83. }
  84. // ------------------------------------------------------------------
  85. // Template/fragment loading
  86. // ------------------------------------------------------------------
  87. private void load_templates_and_fragments(string page_file) throws GLib.Error {
  88. foreach (var dir in template_dirs) {
  89. var dir_file = File.new_for_path(dir);
  90. if (!dir_file.query_exists()) {
  91. continue;
  92. }
  93. var enumerator = dir_file.enumerate_children("standard::name", 0);
  94. FileInfo info;
  95. while ((info = enumerator.next_file()) != null) {
  96. var name = info.get_name();
  97. if (!name.has_suffix(".html") || name.has_prefix(".")) {
  98. continue;
  99. }
  100. var path = Path.build_filename(dir, name);
  101. if (path == page_file) {
  102. continue;
  103. }
  104. var tdoc = parse_html_file(path);
  105. var source = Path.build_filename(dir, name);
  106. validate_document(tdoc, source);
  107. validate_head_directives(tdoc, source);
  108. var tname = read_pstm_name(tdoc);
  109. if (tname == null) {
  110. continue;
  111. }
  112. if (find_first_element(tdoc->get_root_element(), "pstm-content") != null) {
  113. templates_by_name.set((!)tname, tdoc);
  114. } else {
  115. fragments_by_name.set((!)tname, tdoc);
  116. }
  117. }
  118. }
  119. }
  120. // ------------------------------------------------------------------
  121. // Composition
  122. // ------------------------------------------------------------------
  123. private Xml.Doc* compose_templates(Xml.Doc* doc) throws GLib.Error {
  124. Xml.Doc* current_doc = doc;
  125. string merged_title = read_directive_in_doc(current_doc, "pstm-title") ?? "";
  126. // Guard against cyclic template chains (A templates B templates A).
  127. var visited = new HashSet<string>();
  128. while (true) {
  129. var parent_name = read_directive_in_doc(current_doc, "pstm-template");
  130. if (parent_name == null || parent_name.length == 0) {
  131. break;
  132. }
  133. if (visited.contains((!)parent_name)) {
  134. break;
  135. }
  136. visited.add((!)parent_name);
  137. Xml.Doc* parent_doc;
  138. if (!templates_by_name.try_get((!)parent_name, out parent_doc)) {
  139. throw new IOError.FAILED(@"Unknown template \"$((!)parent_name)\"");
  140. }
  141. // Merge this document's <head> into the parent's <head> (so
  142. // pages, templates and nested components can all declare head
  143. // tags), then splice its <body> into the parent's content outlet.
  144. merge_head(current_doc, parent_doc);
  145. splice_body(current_doc, parent_doc);
  146. var parent_title = read_directive_in_doc(parent_doc, "pstm-title");
  147. if (parent_title != null && parent_title.length > 0) {
  148. merged_title = merged_title + (!)parent_title;
  149. }
  150. current_doc = parent_doc;
  151. }
  152. apply_title(current_doc, merged_title);
  153. return current_doc;
  154. }
  155. /**
  156. * Copies the children of `child_doc`'s <head> into `parent_doc`'s <head>,
  157. * skipping the consumed structural directives (pstm-template/name/title/
  158. * uri) which are page-level metadata and must not be carried up.
  159. */
  160. private void merge_head(Xml.Doc* child_doc, Xml.Doc* parent_doc) {
  161. var child_head = find_head(child_doc);
  162. var parent_head = find_head(parent_doc);
  163. if (child_head == null || parent_head == null) {
  164. return;
  165. }
  166. for (var child = child_head->children; child != null; child = child->next) {
  167. if (!is_element(child)) {
  168. continue;
  169. }
  170. var name = (string) child->name;
  171. if (name == "pstm-template" || name == "pstm-name" || name == "pstm-title" || name == "pstm-uri") {
  172. continue;
  173. }
  174. var copied = child->doc_copy(parent_doc, 1);
  175. if (copied != null) {
  176. parent_head->add_child(copied);
  177. }
  178. }
  179. }
  180. /**
  181. * Splices `child_doc`'s <body> into `parent_doc`'s <pstm-content> outlet
  182. * (or appends to the parent's <body> when there is no outlet). When the
  183. * child <body> carries `pstm-materialise-as`, it is wrapped in an element
  184. * of that tag (carrying the body's non-pstm attributes); otherwise its
  185. * children are spliced directly.
  186. */
  187. private void splice_body(Xml.Doc* child_doc, Xml.Doc* parent_doc) {
  188. var child_body = find_body(child_doc);
  189. var parent_body = find_body(parent_doc);
  190. if (child_body == null || parent_body == null) {
  191. return;
  192. }
  193. var outlet = find_first_element(parent_body, "pstm-content");
  194. var materialise_as = child_body->get_prop("pstm-materialise-as");
  195. if (materialise_as != null) {
  196. var wrapper = parent_body->new_child(null, (!)materialise_as, null);
  197. wrapper->unlink();
  198. copy_non_pstm_attrs(child_body, wrapper);
  199. for (var child = child_body->children; child != null; child = child->next) {
  200. if (is_structural_directive(child)) {
  201. continue;
  202. }
  203. var copied = child->doc_copy(parent_doc, 1);
  204. if (copied != null) {
  205. wrapper->add_child(copied);
  206. }
  207. }
  208. if (outlet != null) {
  209. outlet->add_prev_sibling(wrapper);
  210. } else {
  211. parent_body->add_child(wrapper);
  212. }
  213. } else {
  214. for (var child = child_body->children; child != null; child = child->next) {
  215. if (is_structural_directive(child)) {
  216. continue;
  217. }
  218. var copied = child->doc_copy(parent_doc, 1);
  219. if (copied == null) {
  220. continue;
  221. }
  222. if (outlet != null) {
  223. outlet->add_prev_sibling(copied);
  224. } else {
  225. parent_body->add_child(copied);
  226. }
  227. }
  228. }
  229. if (outlet != null) {
  230. outlet->unlink();
  231. }
  232. }
  233. /** Copies an element's non-`pstm-*` attributes onto another element. */
  234. private static void copy_non_pstm_attrs(Xml.Node* src, Xml.Node* dst) {
  235. for (var attr = src->properties; attr != null; attr = attr->next) {
  236. var attr_name = (string) attr->name;
  237. if (attr_name.has_prefix("pstm-")) {
  238. continue;
  239. }
  240. var value = src->get_prop(attr_name);
  241. if (value != null) {
  242. dst->set_prop(attr_name, (!)value);
  243. }
  244. }
  245. }
  246. private static string? read_directive_in_doc(Xml.Doc* doc, string name) {
  247. var root = doc->get_root_element();
  248. if (root == null) {
  249. return null;
  250. }
  251. return read_directive_text(root, name);
  252. }
  253. private static bool is_structural_directive(Xml.Node* node) {
  254. if (!is_element(node)) {
  255. return false;
  256. }
  257. var name = (string) node->name;
  258. return name == "pstm-template" || name == "pstm-name" || name == "pstm-title" || name == "pstm-uri" || name == "pstm-content";
  259. }
  260. /** Fragment-body metadata elements skipped when inlining (e.g. pstm-input). */
  261. private static bool is_fragment_metadata(Xml.Node* node) {
  262. if (!is_element(node)) {
  263. return false;
  264. }
  265. var name = (string) node->name;
  266. return name == "pstm-input" || name.has_prefix("pstm-");
  267. }
  268. private void inline_fragments(Xml.Doc* doc) {
  269. bool changed = true;
  270. while (changed) {
  271. changed = false;
  272. var includes = find_all_elements(doc, "pstm-fragment");
  273. foreach (var include in includes) {
  274. if (inline_one_fragment(doc, include)) {
  275. changed = true;
  276. }
  277. }
  278. }
  279. }
  280. private bool inline_one_fragment(Xml.Doc* doc, Xml.Node* include) {
  281. var name = include->get_prop("name");
  282. if (name == null) {
  283. return false;
  284. }
  285. Xml.Doc* fragment_doc;
  286. if (!fragments_by_name.try_get((!)name, out fragment_doc)) {
  287. return false;
  288. }
  289. var fragment_body = find_body(fragment_doc);
  290. if (fragment_body == null) {
  291. return false;
  292. }
  293. // Merge the fragment's <head> into the document's <head>.
  294. merge_head(fragment_doc, doc);
  295. var inputs = collect_input_names(fragment_body);
  296. var include_materialise = include->get_prop("pstm-materialise-as");
  297. var fragment_materialise = fragment_body->get_prop("pstm-materialise-as");
  298. var materialise_as = include_materialise ?? fragment_materialise;
  299. bool wrap = (materialise_as != null) || inputs.length > 0;
  300. string tag = materialise_as ?? "div";
  301. if (wrap) {
  302. var parent = include->parent;
  303. if (parent == null) {
  304. return false;
  305. }
  306. var wrapper = parent->new_child(null, tag, null);
  307. wrapper->unlink();
  308. include->add_prev_sibling(wrapper);
  309. // Carry the fragment <body>'s attributes when it supplied the tag.
  310. if (include_materialise == null && fragment_materialise != null) {
  311. copy_non_pstm_attrs(fragment_body, wrapper);
  312. }
  313. for (var child = fragment_body->children; child != null; child = child->next) {
  314. if (is_fragment_metadata(child)) {
  315. continue;
  316. }
  317. var copied = child->doc_copy(doc, 1);
  318. if (copied != null) {
  319. wrapper->add_child(copied);
  320. }
  321. }
  322. foreach (var input_name in inputs) {
  323. var expr = include->get_prop(input_name);
  324. if (expr != null) {
  325. wrapper->set_prop(@"stm-def-$input_name-as", (!)expr);
  326. }
  327. }
  328. } else {
  329. for (var child = fragment_body->children; child != null; child = child->next) {
  330. if (is_fragment_metadata(child)) {
  331. continue;
  332. }
  333. var copied = child->doc_copy(doc, 1);
  334. if (copied != null) {
  335. include->add_prev_sibling(copied);
  336. }
  337. }
  338. }
  339. include->unlink();
  340. return true;
  341. }
  342. private string[] collect_input_names(Xml.Node* fragment_body) {
  343. var names = new string[0];
  344. for (var n = fragment_body->children; n != null; n = n->next) {
  345. if (is_element(n) && (string) n->name == "pstm-input") {
  346. var input_name = n->get_prop("name");
  347. if (input_name != null) {
  348. names += (!)input_name;
  349. }
  350. }
  351. }
  352. return names;
  353. }
  354. private void rewrite_resources(Xml.Doc* doc) {
  355. var root = doc->get_root_element();
  356. if (root != null) {
  357. rewrite_resources_in(root);
  358. }
  359. }
  360. private void rewrite_resources_in(Xml.Node* node) {
  361. if (!is_element(node)) {
  362. return;
  363. }
  364. string[] to_add_attr = new string[0];
  365. string[] to_add_val = new string[0];
  366. string[] to_remove = new string[0];
  367. for (var attr = node->properties; attr != null; attr = attr->next) {
  368. var attr_name = (string) attr->name;
  369. if (attr_name.has_prefix("pstm-res-")) {
  370. var target_attr = attr_name.substring("pstm-res-".length);
  371. var value = node->get_prop(attr_name);
  372. if (value != null) {
  373. to_add_attr += target_attr;
  374. to_add_val += @"/_statum/resource/$((!)value)";
  375. }
  376. to_remove += attr_name;
  377. }
  378. }
  379. for (int i = 0; i < to_add_attr.length; i++) {
  380. node->set_prop(to_add_attr[i], to_add_val[i]);
  381. }
  382. foreach (var attr_name in to_remove) {
  383. node->unset_prop(attr_name);
  384. }
  385. for (var child = node->children; child != null; child = child->next) {
  386. rewrite_resources_in(child);
  387. }
  388. }
  389. // ------------------------------------------------------------------
  390. // Variant rendering
  391. // ------------------------------------------------------------------
  392. /**
  393. * Renders the composed document to its single HTML form (stripping every
  394. * `pstm-*` directive) and precompresses it. Pages no longer have
  395. * variants: a page is a static, precompressed document, so there is
  396. * exactly one.
  397. */
  398. private VariantData build_variant(Xml.Doc* doc) throws GLib.Error {
  399. strip_pstm(doc);
  400. var html = serialize(doc);
  401. return compress_variant(html);
  402. }
  403. private VariantData compress_variant(string html) throws GLib.Error {
  404. var data = new VariantData();
  405. data.identity = html.data;
  406. var buffer = new ByteBuffer.from_byte_array(html.data);
  407. var gzip = new GzipCompressor(9).compress_buffer(buffer, null).to_array();
  408. var zstd = new ZstdCompressor(19).compress_buffer(buffer, null).to_array();
  409. var br = new BrotliCompressor(11).compress_buffer(buffer, null).to_array();
  410. data.has_gzip = gzip.length < html.length;
  411. data.has_zstd = zstd.length < html.length;
  412. data.has_br = br.length < html.length;
  413. data.gzip = data.has_gzip ? gzip : null;
  414. data.zstd = data.has_zstd ? zstd : null;
  415. data.br = data.has_br ? br : null;
  416. return data;
  417. }
  418. /** Remove every `pstm-*` attribute and `pstm-*` element from the tree. */
  419. private void strip_pstm(Xml.Doc* doc) {
  420. var root = doc->get_root_element();
  421. if (root != null) {
  422. strip_pstm_from(root);
  423. }
  424. }
  425. private void strip_pstm_from(Xml.Node* node) {
  426. string[] to_remove = new string[0];
  427. for (var attr = node->properties; attr != null; attr = attr->next) {
  428. var attr_name = (string) attr->name;
  429. if (attr_name.has_prefix("pstm-")) {
  430. to_remove += attr_name;
  431. }
  432. }
  433. foreach (var attr_name in to_remove) {
  434. node->unset_prop(attr_name);
  435. }
  436. var child = node->children;
  437. while (child != null) {
  438. var next = child->next;
  439. if (is_element(child) && ((string) child->name).has_prefix("pstm-")) {
  440. child->unlink();
  441. } else if (is_element(child)) {
  442. strip_pstm_from(child);
  443. }
  444. child = next;
  445. }
  446. }
  447. /**
  448. * Errors if any removed server-side conditional (`pstm-if`/`pstm-else-if`/
  449. * `pstm-else`) is still present; pages are static now, so conditionals
  450. * are client-side `stm-if` only.
  451. */
  452. private void validate_no_conditionals(Xml.Doc* doc, string source) throws GLib.Error {
  453. var root = doc->get_root_element();
  454. if (root != null) {
  455. validate_no_conditionals_from(root, source);
  456. }
  457. }
  458. private void validate_no_conditionals_from(Xml.Node* node, string source) throws GLib.Error {
  459. if (!is_element(node)) {
  460. return;
  461. }
  462. string[] names = { "pstm-if", "pstm-else-if", "pstm-else" };
  463. foreach (var name in names) {
  464. if (node->get_prop(name) != null) {
  465. throw new IOError.FAILED(@"\"$source\": the \"$name\" directive is no longer supported; use the client-side stm-if instead");
  466. }
  467. }
  468. for (var child = node->children; child != null; child = child->next) {
  469. validate_no_conditionals_from(child, source);
  470. }
  471. }
  472. // ------------------------------------------------------------------
  473. // Code generation
  474. // ------------------------------------------------------------------
  475. private string emit(string class_name, string route, VariantData variant) throws GLib.Error {
  476. var page_hash = compute_hash(variant.identity);
  477. var b = new StringBuilder();
  478. string i1 = namespace_name != null ? " " : "";
  479. string i2 = namespace_name != null ? " " : " ";
  480. string i3 = namespace_name != null ? " " : " ";
  481. b.append("using Statum;\n");
  482. b.append("using Invercargill;\n");
  483. b.append("using Invercargill.DataStructures;\n");
  484. b.append("using Astralis;\n\n");
  485. if (namespace_name != null) {
  486. b.append_printf("namespace %s {\n\n", namespace_name);
  487. }
  488. b.append_printf("%s// Generated by statum-mkpstm\n", i1);
  489. b.append_printf("%spublic class %s : StatumPage {\n\n", i1, class_name);
  490. b.append_printf("%spublic const string ROUTE = \"%s\";\n", i2, escape(route));
  491. b.append_printf("%spublic override string content_type { get { return \"text/html; charset=utf-8\"; } }\n\n", i2);
  492. // get_best_encoding: smallest available supported encoding.
  493. b.append_printf("%spublic override string get_best_encoding(Set<string> supported) {\n", i2);
  494. if (variant.has_br) {
  495. b.append_printf("%sif (supported.has(\"br\")) return \"br\";\n", i3);
  496. }
  497. if (variant.has_zstd) {
  498. b.append_printf("%sif (supported.has(\"zstd\")) return \"zstd\";\n", i3);
  499. }
  500. if (variant.has_gzip) {
  501. b.append_printf("%sif (supported.has(\"gzip\")) return \"gzip\";\n", i3);
  502. }
  503. b.append_printf("%sreturn \"identity\";\n", i3);
  504. b.append_printf("%s}\n\n", i2);
  505. // get_encoding
  506. b.append_printf("%spublic override unowned uint8[] get_encoding(string encoding) {\n", i2);
  507. b.append_printf("%sswitch (encoding) {\n", i3);
  508. b.append_printf("%scase \"identity\": return IDENTITY;\n", i3);
  509. if (variant.has_gzip && variant.gzip != null) {
  510. b.append_printf("%scase \"gzip\": return GZIP;\n", i3);
  511. }
  512. if (variant.has_zstd && variant.zstd != null) {
  513. b.append_printf("%scase \"zstd\": return ZSTD;\n", i3);
  514. }
  515. if (variant.has_br && variant.br != null) {
  516. b.append_printf("%scase \"br\": return BR;\n", i3);
  517. }
  518. b.append_printf("%sdefault: return IDENTITY;\n", i3);
  519. b.append_printf("%s}\n", i3);
  520. b.append_printf("%s}\n\n", i2);
  521. // get_etag_for (ETag = "<page-hash>-<encoding>")
  522. b.append_printf("%spublic override string get_etag_for(string encoding) {\n", i2);
  523. b.append_printf("%sreturn \"\\\"%s-%%s\\\"\".printf(encoding);\n", i3, page_hash);
  524. b.append_printf("%s}\n\n", i2);
  525. // Byte arrays.
  526. emit_one_array(b, i2, i3, "IDENTITY", variant.identity);
  527. if (variant.has_gzip && variant.gzip != null) {
  528. emit_one_array(b, i2, i3, "GZIP", (!)variant.gzip);
  529. }
  530. if (variant.has_zstd && variant.zstd != null) {
  531. emit_one_array(b, i2, i3, "ZSTD", (!)variant.zstd);
  532. }
  533. if (variant.has_br && variant.br != null) {
  534. emit_one_array(b, i2, i3, "BR", (!)variant.br);
  535. }
  536. b.append_printf("%s}\n", i1);
  537. if (namespace_name != null) {
  538. b.append("}\n");
  539. }
  540. return b.str;
  541. }
  542. private void emit_one_array(StringBuilder b, string i2, string i3, string name, uint8[] data) {
  543. b.append_printf("%sprivate const uint8[] %s = {\n", i2, name);
  544. for (int i = 0; i < data.length; i++) {
  545. if (i == 0) {
  546. b.append(i3);
  547. } else if (i % 16 == 0) {
  548. b.append(",\n").append(i3);
  549. } else {
  550. b.append(", ");
  551. }
  552. b.append_printf("0x%02x", data[i]);
  553. }
  554. b.append_printf("\n%s};\n\n", i2);
  555. }
  556. // ------------------------------------------------------------------
  557. // DOM helpers
  558. // ------------------------------------------------------------------
  559. private Xml.Doc* parse_html_file(string path) throws GLib.Error {
  560. var file = File.new_for_path(path);
  561. if (!file.query_exists()) {
  562. throw new IOError.NOT_FOUND(@"Page file '$path' does not exist");
  563. }
  564. uint8[] contents;
  565. string etag_out;
  566. file.load_contents(null, out contents, out etag_out);
  567. // Parse with libxml2's HTML parser (mirrors Astralis' MarkupDocument)
  568. // so the tree has HTML semantics and serializes as HTML, not XML.
  569. int opts = (int)(Html.ParserOption.RECOVER |
  570. Html.ParserOption.NOERROR |
  571. Html.ParserOption.NOWARNING |
  572. Html.ParserOption.NOBLANKS |
  573. Html.ParserOption.NONET);
  574. char[] buffer = ((string) contents).to_utf8();
  575. var doc = Html.Doc.read_memory(buffer, buffer.length, path, "utf-8", opts);
  576. if (doc == null) {
  577. throw new IOError.FAILED(@"Failed to parse HTML file '$path'");
  578. }
  579. return doc;
  580. }
  581. private static Xml.Node* find_body(Xml.Doc* doc) {
  582. var root = doc->get_root_element();
  583. if (root == null) {
  584. return null;
  585. }
  586. return find_first_element(root, "body");
  587. }
  588. private static Xml.Node* find_head(Xml.Doc* doc) {
  589. var root = doc->get_root_element();
  590. if (root == null) {
  591. return null;
  592. }
  593. return find_first_element(root, "head");
  594. }
  595. /**
  596. * Validates that `doc` has both a <head> and a <body> element, which every
  597. * Statum HTML input (pages, templates and fragments) must declare.
  598. */
  599. private void validate_document(Xml.Doc* doc, string source) throws GLib.Error {
  600. if (find_head(doc) == null) {
  601. throw new IOError.FAILED(@"\"$source\" has no <head> element (a <head> and <body> are required)");
  602. }
  603. if (find_body(doc) == null) {
  604. throw new IOError.FAILED(@"\"$source\" has no <body> element (a <head> and <body> are required)");
  605. }
  606. }
  607. /**
  608. * Validates that the structural directives <pstm-title>, <pstm-template>
  609. * and <pstm-name> appear only inside <head>.
  610. */
  611. private void validate_head_directives(Xml.Doc* doc, string source) throws GLib.Error {
  612. var root = doc->get_root_element();
  613. if (root == null) {
  614. return;
  615. }
  616. string[] names = { "pstm-title", "pstm-template", "pstm-name", "pstm-uri" };
  617. foreach (var name in names) {
  618. var nodes = new Series<Xml.Node*>();
  619. find_all_from(root, name, nodes);
  620. foreach (var node in nodes) {
  621. if (!is_descendant_of_head(node)) {
  622. throw new IOError.FAILED(@"\"$source\": <$name> must appear inside <head>");
  623. }
  624. }
  625. }
  626. }
  627. /** Walks the ancestor chain to determine whether `node` is within <head>. */
  628. private static bool is_descendant_of_head(Xml.Node* node) {
  629. for (var p = node->parent; p != null; p = p->parent) {
  630. if (is_element(p) && ((string) p->name).down() == "head") {
  631. return true;
  632. }
  633. }
  634. return false;
  635. }
  636. private static Xml.Node* find_first_element(Xml.Node* root, string name) {
  637. if (!is_element(root)) {
  638. return null;
  639. }
  640. if (((string) root->name).down() == name.down()) {
  641. return root;
  642. }
  643. for (var child = root->children; child != null; child = child->next) {
  644. var found = find_first_element(child, name);
  645. if (found != null) {
  646. return found;
  647. }
  648. }
  649. return null;
  650. }
  651. private static Xml.Node*[] find_all_elements(Xml.Doc* doc, string name) {
  652. var collected = new Series<Xml.Node*>();
  653. var root = doc->get_root_element();
  654. if (root != null) {
  655. find_all_from(root, name, collected);
  656. }
  657. return collected.to_array();
  658. }
  659. private static void find_all_from(Xml.Node* node, string name, Series<Xml.Node*> result) {
  660. if (is_element(node) && ((string) node->name).down() == name.down()) {
  661. result.add(node);
  662. }
  663. if (is_element(node)) {
  664. for (var child = node->children; child != null; child = child->next) {
  665. find_all_from(child, name, result);
  666. }
  667. }
  668. }
  669. private static string? read_directive_text(Xml.Node* body, string directive) {
  670. var node = find_first_element(body, directive);
  671. if (node == null) {
  672. return null;
  673. }
  674. var text = element_text(node);
  675. return text.strip().length > 0 ? text.strip() : null;
  676. }
  677. private static string? read_pstm_name(Xml.Doc* doc) {
  678. var node = find_first_element(doc->get_root_element(), "pstm-name");
  679. if (node == null) {
  680. return null;
  681. }
  682. return element_text(node).strip();
  683. }
  684. private static string[] read_routes(Xml.Doc* doc) {
  685. string[] routes = new string[0];
  686. var nodes = find_all_elements(doc, "pstm-uri");
  687. foreach (var node in nodes) {
  688. var text = element_text(node).strip();
  689. if (text.length > 0) {
  690. routes += text;
  691. }
  692. }
  693. return routes;
  694. }
  695. private static string element_text(Xml.Node* node) {
  696. var b = new StringBuilder();
  697. for (var child = node->children; child != null; child = child->next) {
  698. if (child->type == ElementType.TEXT_NODE && child->content != null) {
  699. b.append((string) child->content);
  700. } else if (is_element(child)) {
  701. b.append(element_text(child));
  702. }
  703. }
  704. return b.str;
  705. }
  706. private static void apply_title(Xml.Doc* doc, string? title) {
  707. if (title == null || title.length == 0) {
  708. return;
  709. }
  710. var head = find_first_element(doc->get_root_element(), "head");
  711. if (head == null) {
  712. return;
  713. }
  714. var title_node = find_first_element(head, "title");
  715. if (title_node == null) {
  716. title_node = head->new_child(null, "title", null);
  717. }
  718. for (var child = title_node->children; child != null; child = child->next) {
  719. child->unlink();
  720. }
  721. title_node->add_content(title);
  722. }
  723. private static string serialize(Xml.Doc* doc) {
  724. // Serialize with libxml2's HTML serializer (htmlDocDumpMemory) so the
  725. // output is valid HTML: no <?xml?> prolog, no self-closing
  726. // `<script/>`, and correct void/non-void element handling. The doc was
  727. // produced by the HTML parser, so it carries HTML structure.
  728. string mem;
  729. int len;
  730. ((Html.Doc*) doc)->dump_memory(out mem, out len);
  731. return len > 0 ? mem.substring(0, len) : mem;
  732. }
  733. private static bool is_element(Xml.Node* node) {
  734. return node != null && node->type == ElementType.ELEMENT_NODE;
  735. }
  736. private static string compute_hash(uint8[] data) {
  737. var checksum = new Checksum(ChecksumType.SHA512);
  738. checksum.update(data, data.length);
  739. return checksum.get_string();
  740. }
  741. private static string escape(string s) {
  742. return s.replace("\\", "\\\\").replace("\"", "\\\"");
  743. }
  744. private static void write_file(string path, string contents) throws GLib.Error {
  745. var file = File.new_for_path(path);
  746. var stream = new DataOutputStream(file.replace(null, false, FileCreateFlags.NONE));
  747. stream.put_string(contents);
  748. stream.close();
  749. }
  750. private static string make_pascal_case(string input) {
  751. var basename = Path.get_basename(input);
  752. var dot = basename.last_index_of(".");
  753. if (dot > 0) {
  754. basename = basename.substring(0, dot);
  755. }
  756. var result = new StringBuilder();
  757. var capitalize_next = true;
  758. foreach (var c in basename.data) {
  759. if (c == '-' || c == '_' || c == ' ' || c == '.') {
  760. capitalize_next = true;
  761. } else {
  762. if (capitalize_next) {
  763. result.append_printf("%c", c >= 'a' && c <= 'z' ? c - 32 : c);
  764. capitalize_next = false;
  765. } else {
  766. result.append_printf("%c", c);
  767. }
  768. }
  769. }
  770. return result.str;
  771. }
  772. // ------------------------------------------------------------------
  773. // Model
  774. // ------------------------------------------------------------------
  775. private class VariantData : Object {
  776. public uint8[] identity;
  777. public uint8[]? gzip;
  778. public uint8[]? zstd;
  779. public uint8[]? br;
  780. public bool has_gzip;
  781. public bool has_zstd;
  782. public bool has_br;
  783. }
  784. }
  785. }