statum-mkpstm.vala 40 KB

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