statum-mkpstm.vala 40 KB

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