spry.vala 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. using GLib;
  2. namespace Spry.Cli {
  3. /**
  4. * `spry` — the Spry application CLI.
  5. *
  6. * Scaffolds Statum + Spry applications (`spry new`, including their USM
  7. * packaging), grows them with app-owned page/action/resource/auth
  8. * templates (`spry add …`), maintains the static key material in
  9. * `web-config.json` (`spry keys`), and ships them as container images
  10. * through USM (`spry deploy`). Edits to generated projects happen only
  11. * within `// spry:*-begin/end` markers in `src/main.vala` and
  12. * `# spry:*-begin/end` markers in `meson.build`, so `add` commands are
  13. * idempotent and never reformat surrounding user code.
  14. */
  15. public class SpryCli : Object {
  16. private static string? tools_dir = null;
  17. private static string? libdir = null;
  18. private static bool force = false;
  19. private static string? route = null;
  20. private static string? out_path = null;
  21. private static string? usm = null;
  22. private static string? deploy_exec = null;
  23. private static string? deploy_base = null;
  24. [CCode (array_length = false, null_terminated = true)]
  25. private static string[] deploy_repositories = null;
  26. private static string? deploy_installer_url = null;
  27. private static bool deploy_no_build = false;
  28. private static bool deploy_verbose = false;
  29. private static int dev_port = 8080;
  30. private static bool dev_fresh = false;
  31. private static bool dev_no_run = false;
  32. private const OptionEntry[] global_options = {
  33. { "tools-dir", '\0', 0, OptionArg.STRING, ref tools_dir, "Directory containing the statum-* tools (default: search PATH, then ~/.local/bin)", "DIR" },
  34. { "libdir", '\0', 0, OptionArg.STRING, ref libdir, "Library directory prepended to spawned tools' LD_LIBRARY_PATH/PKG_CONFIG_PATH (default: pkg-config spry-0.2 libdir)", "DIR" },
  35. { null }
  36. };
  37. private const OptionEntry[] new_options = {
  38. { "force", 'f', 0, OptionArg.NONE, ref force, "Overwrite generated files when the target directory already exists", null },
  39. { null }
  40. };
  41. private const OptionEntry[] page_options = {
  42. { "route", 'r', 0, OptionArg.STRING, ref route, "Page route (default: /<name>)", "PATH" },
  43. { null }
  44. };
  45. private const OptionEntry[] keys_options = {
  46. { "out", 'o', 0, OptionArg.FILENAME, ref out_path, "Configuration file to merge into (default: web-config.json)", "FILE" },
  47. { null }
  48. };
  49. private const OptionEntry[] deploy_options = {
  50. { "usm", '\0', 0, OptionArg.FILENAME, ref usm, "usm binary to delegate to (default: search PATH)", "FILE" },
  51. { "exec", '\0', 0, OptionArg.STRING, ref deploy_exec, "Container command (default: \"<app> 8080\")", "CMD" },
  52. { "base", '\0', 0, OptionArg.STRING, ref deploy_base, "Base image (default: usm's fedora:43)", "IMAGE" },
  53. { "repository", '\0', 0, OptionArg.STRING_ARRAY, ref deploy_repositories, "USM repository (.usmr) to resolve from; repeatable (default: the machine's configured repositories)", "FILE" },
  54. { "installer-url", '\0', 0, OptionArg.STRING, ref deploy_installer_url, "USM installer source override (file://… for local testing)", "URL" },
  55. { "no-build", '\0', 0, OptionArg.NONE, ref deploy_no_build, "Only generate the deploy context; do not build the image", null },
  56. { "verbose", 'v', 0, OptionArg.NONE, ref deploy_verbose, "Stream package script output to the terminal during the image build", null },
  57. { null }
  58. };
  59. private const OptionEntry[] dev_options = {
  60. { "port", 'p', 0, OptionArg.INT, ref dev_port, "Port to run the application on (default: 8080)", "N" },
  61. { "fresh", '\0', 0, OptionArg.NONE, ref dev_fresh, "Delete the SQLite database before starting", null },
  62. { "no-run", '\0', 0, OptionArg.NONE, ref dev_no_run, "Build once and exit; do not run or watch", null },
  63. { null }
  64. };
  65. private const string MESON_PAGES_BEGIN = "# spry:pages-begin";
  66. private const string MESON_PAGES_END = "# spry:pages-end";
  67. private const string MESON_RESOURCES_BEGIN = "# spry:resources-begin";
  68. private const string MESON_RESOURCES_END = "# spry:resources-end";
  69. private const string MESON_SOURCES_BEGIN = "# spry:sources-begin";
  70. private const string MESON_SOURCES_END = "# spry:sources-end";
  71. private const string MESON_GENERATED_BEGIN = "# spry:generated-begin";
  72. private const string MESON_GENERATED_END = "# spry:generated-end";
  73. private const string VALA_PAGES_BEGIN = "// spry:pages-begin";
  74. private const string VALA_PAGES_END = "// spry:pages-end";
  75. private const string VALA_ACTIONS_BEGIN = "// spry:actions-begin";
  76. private const string VALA_ACTIONS_END = "// spry:actions-end";
  77. private const string VALA_RESOURCES_BEGIN = "// spry:resources-begin";
  78. private const string VALA_RESOURCES_END = "// spry:resources-end";
  79. private const string VALA_AUTH_BEGIN = "// spry:auth-begin";
  80. private const string VALA_AUTH_END = "// spry:auth-end";
  81. private const string NAV_BEGIN = "<!-- spry:nav-begin -->";
  82. private const string NAV_END = "<!-- spry:nav-end -->";
  83. private const string NAV_AUTH_BEGIN = "<!-- spry:nav-auth-begin -->";
  84. private const string NAV_AUTH_END = "<!-- spry:nav-auth-end -->";
  85. public static int main(string[] args) {
  86. if (args.length < 2) {
  87. print_usage();
  88. return 1;
  89. }
  90. var command = args[1];
  91. if (command == "--help" || command == "-h" || command == "help") {
  92. print_usage();
  93. return 0;
  94. }
  95. try {
  96. switch (command) {
  97. case "new":
  98. return cmd_new(slice(args, 1));
  99. case "add":
  100. return cmd_add(slice(args, 1));
  101. case "keys":
  102. return cmd_keys(slice(args, 1));
  103. case "deploy":
  104. return cmd_deploy(slice(args, 1));
  105. case "dev":
  106. return cmd_dev(slice(args, 1));
  107. default:
  108. stderr.printf("Error: Unknown command '%s'.\n", command);
  109. print_usage();
  110. return 1;
  111. }
  112. } catch (OptionError e) {
  113. stderr.printf("Error: %s\n", e.message);
  114. return 1;
  115. } catch (Error e) {
  116. stderr.printf("Error: %s\n", e.message);
  117. return 1;
  118. }
  119. }
  120. private static void print_usage() {
  121. stdout.printf(
  122. "Usage:\n" +
  123. " spry <command> [options]\n" +
  124. "\n" +
  125. "Commands:\n" +
  126. " new <name> Scaffold a new Statum + Spry application\n" +
  127. " add page <name> [--route PATH] Add a page (HTML + entrypoint + wiring)\n" +
  128. " add action <name> Add a StatumAction skeleton\n" +
  129. " add resource <file> Embed a static resource (statum-mkres)\n" +
  130. " add login Add the login page (Spry.Actions.LoginAction)\n" +
  131. " add register Add the registration page\n" +
  132. " add user-management Add the admin user-management page\n" +
  133. " keys [--out FILE] Generate/merge static keys into web-config.json\n" +
  134. " deploy [--exec CMD] [--base I] Build the USM container image (usm manifest\n" +
  135. " deploy; --repository/--installer-url/\n" +
  136. " --no-build pass through)\n" +
  137. " dev [--port N] [--fresh] Build, run and watch: rebuild + restart on save\n" +
  138. "\n" +
  139. "Options:\n" +
  140. " --tools-dir DIR Directory containing the statum-* tools (default: PATH)\n" +
  141. " --libdir DIR Library dir for spawned tools' LD_LIBRARY_PATH\n" +
  142. " (default: pkg-config spry-0.2 libdir)\n" +
  143. " --help Show this help\n");
  144. }
  145. private static void print_add_usage() {
  146. stdout.printf(
  147. "Usage:\n" +
  148. " spry add page <name> [--route /path]\n" +
  149. " spry add action <name>\n" +
  150. " spry add resource <file>\n" +
  151. " spry add login\n" +
  152. " spry add register\n" +
  153. " spry add user-management\n");
  154. }
  155. // ----------------------------------------------------------------------
  156. // spry new
  157. // ----------------------------------------------------------------------
  158. private static int cmd_new(string[] args) throws Error, OptionError {
  159. parse(args, "<name> - scaffold a new Statum + Spry application", new_options);
  160. if (args.length < 2) {
  161. stderr.printf("Error: No application name specified.\n");
  162. return 1;
  163. }
  164. var name = args[1].down();
  165. if (!Generator.valid_name(name)) {
  166. stderr.printf("Error: '%s' is not a valid application name (letters, digits, '-' and '_', starting with a letter).\n", name);
  167. return 1;
  168. }
  169. var app_dir = Path.build_filename(Environment.get_current_dir(), name);
  170. if (FileUtils.test(app_dir, FileTest.EXISTS) && !force) {
  171. stderr.printf("Error: %s already exists (use --force to overwrite its generated files).\n", app_dir);
  172. return 1;
  173. }
  174. var vars = base_vars(name);
  175. write_app_file(app_dir, "meson.build", Generator.fill(Templates.PROJECT_MESON, vars));
  176. write_app_file(app_dir, "src/main.vala", Generator.fill(Templates.APP_MAIN, vars));
  177. write_app_file(app_dir, "src/pages/main.html", Generator.fill(Templates.LAYOUT, vars));
  178. write_app_file(app_dir, "src/pages/home.html", Generator.fill(Templates.HOME, vars));
  179. write_app_file(app_dir, "src/entrypoints/HomeEntrypoint.vala", Generator.fill(Templates.HOME_ENTRYPOINT, vars));
  180. write_app_file(app_dir, ".gitignore", Generator.fill(Templates.GITIGNORE, vars));
  181. write_app_file(app_dir, "README.md", Generator.fill(Templates.README, vars));
  182. // USM packaging: the manifest + scripts that let `spry deploy`
  183. // (usm manifest deploy) build and install the app in-container
  184. write_app_file(app_dir, "MANIFEST.usm", Generator.fill(Templates.MANIFEST_USM, vars));
  185. write_app_file(app_dir, "usm-scripts/build.sh", Templates.USM_BUILD_SH, 0755);
  186. write_app_file(app_dir, "usm-scripts/install.sh", Templates.USM_INSTALL_SH, 0755);
  187. write_app_file(app_dir, ".usmignore", Templates.USMIGNORE);
  188. var config_path = Path.build_filename(app_dir, "web-config.json");
  189. try {
  190. Keys.run(config_path, tools_dir, libdir);
  191. } catch (Error e) {
  192. stderr.printf("Warning: %s — wrote an empty config; run `spry keys` later.\n", e.message);
  193. Generator.write_file(config_path, "{\n \"statum\": {\n }\n}\n");
  194. }
  195. CompileCommands.build_initial(app_dir, libdir);
  196. stdout.printf("Created %s\n", app_dir);
  197. stdout.printf("Next:\n cd %s\n spry dev\n", name);
  198. return 0;
  199. }
  200. // ----------------------------------------------------------------------
  201. // spry add …
  202. // ----------------------------------------------------------------------
  203. private static int cmd_add(string[] args) throws Error, OptionError {
  204. if (args.length < 2) {
  205. print_add_usage();
  206. return 1;
  207. }
  208. var what = args[1];
  209. var rest = slice(args, 1);
  210. switch (what) {
  211. case "page":
  212. return cmd_add_page(rest);
  213. case "action":
  214. return cmd_add_action(rest);
  215. case "resource":
  216. return cmd_add_resource(rest);
  217. case "login":
  218. return cmd_add_login(rest);
  219. case "register":
  220. return cmd_add_register(rest);
  221. case "user-management":
  222. return cmd_add_user_management(rest);
  223. default:
  224. stderr.printf("Error: Unknown add command '%s'.\n", what);
  225. print_add_usage();
  226. return 1;
  227. }
  228. }
  229. private static int cmd_add_page(string[] args) throws Error, OptionError {
  230. parse(args, "<name> - add a page (HTML + entrypoint + wiring)", page_options);
  231. if (args.length < 2) {
  232. stderr.printf("Error: No page name specified.\n");
  233. return 1;
  234. }
  235. var name = normalise(args[1]);
  236. if (!Generator.valid_name(name)) {
  237. stderr.printf("Error: '%s' is not a valid page name.\n", name);
  238. return 1;
  239. }
  240. var route_path = route != null ? (!)route : "/" + name;
  241. if (!route_path.has_prefix("/")) {
  242. route_path = "/" + route_path;
  243. }
  244. var app = require_app();
  245. add_page(app, name, Generator.pascal_case(name), route_path,
  246. Generator.fill(Templates.PAGE, page_vars(app, name, route_path)),
  247. Generator.fill(Templates.PAGE_ENTRYPOINT, page_vars(app, name, route_path)));
  248. stdout.printf("Added page %s at %s\n", name, route_path);
  249. return 0;
  250. }
  251. private static int cmd_add_action(string[] args) throws Error, OptionError {
  252. parse(args, "<name> - add a StatumAction skeleton", null);
  253. if (args.length < 2) {
  254. stderr.printf("Error: No action name specified.\n");
  255. return 1;
  256. }
  257. var name = normalise(args[1]);
  258. if (!Generator.valid_name(name)) {
  259. stderr.printf("Error: '%s' is not a valid action name.\n", name);
  260. return 1;
  261. }
  262. var cls = Generator.pascal_case(name);
  263. var app = require_app();
  264. var vars = app.vars;
  265. vars.set("CLASS", cls);
  266. write_new_app_file(app.dir, "src/actions/%sAction.vala".printf(cls),
  267. Generator.fill(Templates.ACTION, vars));
  268. edit_meson(app, "'src/actions/%sAction.vala',".printf(cls), MESON_SOURCES_BEGIN, MESON_SOURCES_END);
  269. edit_main(app, "statum.action<%sAction>();".printf(cls), VALA_ACTIONS_BEGIN, VALA_ACTIONS_END);
  270. stdout.printf("Added action %s\n", cls);
  271. return 0;
  272. }
  273. private static int cmd_add_resource(string[] args) throws Error, OptionError {
  274. parse(args, "<file> - embed a static resource", null);
  275. if (args.length < 2) {
  276. stderr.printf("Error: No resource file specified.\n");
  277. return 1;
  278. }
  279. var source = args[1];
  280. if (!FileUtils.test(source, FileTest.EXISTS)) {
  281. stderr.printf("Error: %s does not exist.\n", source);
  282. return 1;
  283. }
  284. var file_name = Path.get_basename(source);
  285. var stem = file_name.contains(".") ? file_name.substring(0, file_name.last_index_of(".")) : file_name;
  286. var cls = Generator.pascal_case(stem) + "Resource";
  287. var var_name = Generator.snake_case(stem) + "_resource";
  288. var content_type = content_type_for(file_name);
  289. var app = require_app();
  290. var resource_path = Path.build_filename(app.dir, "src", "resources", file_name);
  291. if (!FileUtils.test(resource_path, FileTest.EXISTS)) {
  292. Generator.write_file(resource_path, Generator.read_file(source));
  293. }
  294. var command = "[statum_mkres, '-o', '@OUTPUT@', '-n', '%s', '--class-name', '%s'".printf(file_name, cls);
  295. if (content_type != null) {
  296. command += ", '-c', '%s'".printf((!)content_type);
  297. }
  298. command += ", '@INPUT@']";
  299. var target = "%s = custom_target('%s-resource',\n".printf(var_name, Generator.snake_case(stem))
  300. + " input: 'src/resources/%s',\n".printf(file_name)
  301. + " output: '%s.vala',\n".printf(cls)
  302. + " command: %s\n".printf(command)
  303. + ")";
  304. edit_meson(app, target, MESON_RESOURCES_BEGIN, MESON_RESOURCES_END);
  305. edit_meson(app, "%s,".printf(var_name), MESON_GENERATED_BEGIN, MESON_GENERATED_END);
  306. edit_main(app, "statum.add_resource<%s>();".printf(cls), VALA_RESOURCES_BEGIN, VALA_RESOURCES_END);
  307. stdout.printf("Added resource %s as %s\n", source, cls);
  308. return 0;
  309. }
  310. private static int cmd_add_login(string[] args) throws Error, OptionError {
  311. parse(args, "- add the login page", null);
  312. var app = require_app();
  313. add_page(app, "login", "Login", "/login",
  314. Generator.fill(Templates.LOGIN, app.vars),
  315. Generator.fill(Templates.LOGIN_ENTRYPOINT, app.vars));
  316. // Keep Spry.Actions registrations in sync with
  317. // SpryAuth.register_actions (Spry/src/Auth.vala), the canonical
  318. // list of shipped actions.
  319. edit_main(app, "statum.action<Spry.Actions.LoginAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  320. edit_main(app, "statum.action<Spry.Actions.LogoutAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  321. edit_layout(app, "<a href=\"/login\" stm-if=\"!auth\">Login</a>", NAV_BEGIN, NAV_END);
  322. edit_layout(app, "<button type=\"button\" stm-action=\"auth.logout\">Log out</button>", NAV_AUTH_BEGIN, NAV_AUTH_END);
  323. Generator.append_section(Path.build_filename(app.dir, "README.md"),
  324. "## Guarding pages", Generator.fill(Templates.LOGIN_README, app.vars));
  325. stdout.printf("Added login page at /login\n");
  326. return 0;
  327. }
  328. private static int cmd_add_register(string[] args) throws Error, OptionError {
  329. parse(args, "- add the registration page", null);
  330. var app = require_app();
  331. add_page(app, "register", "Register", "/register",
  332. Generator.fill(Templates.REGISTER, app.vars),
  333. Generator.fill(Templates.REGISTER_ENTRYPOINT, app.vars));
  334. edit_main(app, "statum.action<Spry.Actions.RegisterAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  335. edit_layout(app, "<a href=\"/register\" stm-if=\"!auth\">Register</a>", NAV_BEGIN, NAV_END);
  336. stdout.printf("Added register page at /register\n");
  337. return 0;
  338. }
  339. private static int cmd_add_user_management(string[] args) throws Error, OptionError {
  340. parse(args, "- add the admin user-management page", null);
  341. var app = require_app();
  342. add_page(app, "user-management", "UserManagement", "/users",
  343. Generator.fill(Templates.USER_MANAGEMENT, app.vars),
  344. Generator.fill(Templates.USER_MANAGEMENT_ENTRYPOINT, app.vars));
  345. edit_main(app, "statum.action<Spry.Actions.SetUserEnabledAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  346. edit_main(app, "statum.action<Spry.Actions.GrantPermissionAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  347. edit_main(app, "statum.action<Spry.Actions.RevokePermissionAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  348. edit_main(app, "statum.action<Spry.Actions.AlterUserAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  349. edit_main(app, "statum.action<Spry.Actions.DeleteUserAction>();", VALA_AUTH_BEGIN, VALA_AUTH_END);
  350. edit_layout(app, "<a href=\"/users\" stm-if=\"auth && (auth.permissions.indexOf('admin') !== -1 || auth.permissions.indexOf('*') !== -1)\">Users</a>", NAV_BEGIN, NAV_END);
  351. stdout.printf("Added user-management page at /users\n");
  352. return 0;
  353. }
  354. // ----------------------------------------------------------------------
  355. // spry dev
  356. // ----------------------------------------------------------------------
  357. private static int cmd_dev(string[] args) throws Error, OptionError {
  358. parse(args, "- build, run and watch the application", dev_options);
  359. var app = require_app();
  360. return Dev.run(app.dir, app.name, dev_port, dev_fresh, dev_no_run, libdir);
  361. }
  362. // ----------------------------------------------------------------------
  363. // spry keys / spry deploy
  364. // ----------------------------------------------------------------------
  365. private static int cmd_keys(string[] args) throws Error, OptionError {
  366. parse(args, "- generate and merge static keys into web-config.json", keys_options);
  367. Keys.run(out_path ?? "web-config.json", tools_dir, libdir);
  368. return 0;
  369. }
  370. private static int cmd_deploy(string[] args) throws Error, OptionError {
  371. string[] repositories;
  372. string[] remaining;
  373. extract_repositories(args, out repositories, out remaining);
  374. parse(remaining, "- build the USM container image via usm manifest deploy", deploy_options);
  375. var app = require_app();
  376. return Deploy.run(app.dir, app.name, usm, deploy_exec, deploy_base,
  377. repositories, deploy_installer_url, deploy_no_build, deploy_verbose);
  378. }
  379. /**
  380. * Collects repeatable `--repository FILE` / `--repository=FILE`
  381. * arguments into {@link repositories}, leaving the rest in
  382. * {@link remaining} so the GLib option context (whose string-array
  383. * capture is unreliable here) never sees them.
  384. */
  385. private static void extract_repositories(string[] args, out string[] repositories, out string[] remaining) {
  386. var found = new string[0];
  387. var rest = new string[args.length];
  388. rest[0] = args[0];
  389. int out_index = 1;
  390. for (int i = 1; i < args.length; i++) {
  391. if (args[i] == "--repository" && i + 1 < args.length) {
  392. found += args[++i];
  393. }
  394. else if (args[i].has_prefix("--repository=")) {
  395. found += args[i].substring("--repository=".length);
  396. }
  397. else {
  398. rest[out_index++] = args[i];
  399. }
  400. }
  401. rest.length = out_index;
  402. repositories = found;
  403. remaining = rest;
  404. }
  405. // ----------------------------------------------------------------------
  406. // Shared helpers
  407. // ----------------------------------------------------------------------
  408. /**
  409. * The surrounding spry application: the current directory, the app
  410. * name (directory basename) and the namespace its generated pages
  411. * use (read back from meson.build, falling back to the directory
  412. * name).
  413. */
  414. private class AppContext {
  415. public string dir;
  416. public string name;
  417. public string ns;
  418. public HashTable<string, string> vars;
  419. public AppContext(string dir, string name, string ns) {
  420. this.dir = dir;
  421. this.name = name;
  422. this.ns = ns;
  423. this.vars = new HashTable<string, string>(str_hash, str_equal);
  424. this.vars.insert("APP_NAME", name);
  425. this.vars.insert("NS", ns);
  426. }
  427. }
  428. private static AppContext require_app() throws Error {
  429. var cwd = Environment.get_current_dir();
  430. if (!FileUtils.test(Path.build_filename(cwd, "meson.build"), FileTest.EXISTS)
  431. || !FileUtils.test(Path.build_filename(cwd, "src", "main.vala"), FileTest.EXISTS)) {
  432. throw Generator.cli_error("not a spry application directory (run from the project root)");
  433. }
  434. var name = Path.get_basename(cwd);
  435. var ns = detect_ns(Generator.read_file(Path.build_filename(cwd, "meson.build")));
  436. if (ns.length == 0) {
  437. ns = Generator.pascal_case(name);
  438. }
  439. return new AppContext(cwd, name, ns);
  440. }
  441. private static string detect_ns(string meson) {
  442. var marker = "'--ns', '";
  443. var index = meson.index_of(marker);
  444. if (index < 0) {
  445. return "";
  446. }
  447. var start = index + marker.length;
  448. var stop = meson.index_of("'", start);
  449. return stop > start ? meson.substring(start, stop - start) : "";
  450. }
  451. private static HashTable<string, string> base_vars(string name) {
  452. var vars = new HashTable<string, string>(str_hash, str_equal);
  453. vars.insert("APP_NAME", name);
  454. vars.insert("NS", Generator.pascal_case(name));
  455. return vars;
  456. }
  457. private static HashTable<string, string> page_vars(AppContext app, string name, string route_path) {
  458. app.vars.set("CLASS", Generator.pascal_case(name));
  459. app.vars.set("ROUTE", route_path);
  460. return app.vars;
  461. }
  462. private static void add_page(AppContext app, string name, string cls, string route_path,
  463. string html, string entrypoint) throws Error {
  464. write_new_app_file(app.dir, "src/pages/%s.html".printf(name), html);
  465. write_new_app_file(app.dir, "src/entrypoints/%sEntrypoint.vala".printf(cls), entrypoint);
  466. var var_name = Generator.snake_case(name) + "_page";
  467. var target = "%s = custom_target('%s-page',\n".printf(var_name, Generator.snake_case(name))
  468. + " input: 'src/pages/%s.html',\n".printf(name)
  469. + " output: '%sPage.vala',\n".printf(cls)
  470. + " command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', '%sPage', '--ns', '%s', '@INPUT@'],\n".printf(cls, app.ns)
  471. + " depend_files: files('src/pages/main.html')\n"
  472. + ")";
  473. edit_meson(app, target, MESON_PAGES_BEGIN, MESON_PAGES_END);
  474. edit_meson(app, "'src/entrypoints/%sEntrypoint.vala',".printf(cls), MESON_SOURCES_BEGIN, MESON_SOURCES_END);
  475. edit_meson(app, "%s,".printf(var_name), MESON_GENERATED_BEGIN, MESON_GENERATED_END);
  476. edit_main(app, "statum.add_page<%sPage, %sEntrypoint>();".printf(cls, cls), VALA_PAGES_BEGIN, VALA_PAGES_END);
  477. }
  478. private static void edit_meson(AppContext app, string snippet, string begin, string end) throws Error {
  479. var path = Path.build_filename(app.dir, "meson.build");
  480. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  481. }
  482. private static void edit_main(AppContext app, string snippet, string begin, string end) throws Error {
  483. var path = Path.build_filename(app.dir, "src", "main.vala");
  484. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  485. }
  486. private static void edit_layout(AppContext app, string snippet, string begin, string end) throws Error {
  487. var path = Path.build_filename(app.dir, "src", "pages", "main.html");
  488. Generator.write_file(path, Generator.insert_marked(Generator.read_file(path), begin, end, snippet));
  489. }
  490. private static void write_app_file(string app_dir, string relative, string contents, uint32 mode = 0644) throws Error {
  491. Generator.write_file(Path.build_filename(app_dir, relative), contents, mode);
  492. }
  493. /**
  494. * Writes a scaffolded file only when it does not exist yet, so
  495. * re-running `add` commands never clobbers user edits.
  496. */
  497. private static void write_new_app_file(string app_dir, string relative, string contents) throws Error {
  498. var path = Path.build_filename(app_dir, relative);
  499. if (FileUtils.test(path, FileTest.EXISTS)) {
  500. stdout.printf("%s already exists — left untouched\n", path);
  501. return;
  502. }
  503. Generator.write_file(path, contents);
  504. }
  505. private static string normalise(string name) {
  506. return name.down();
  507. }
  508. private static string? content_type_for(string filename) {
  509. var extension = filename.contains(".") ? filename.substring(filename.last_index_of(".") + 1).down() : "";
  510. switch (extension) {
  511. case "css":
  512. return "text/css";
  513. case "js":
  514. case "mjs":
  515. return "application/javascript";
  516. case "png":
  517. return "image/png";
  518. case "svg":
  519. return "image/svg+xml";
  520. case "jpg":
  521. case "jpeg":
  522. return "image/jpeg";
  523. case "ico":
  524. return "image/x-icon";
  525. case "woff2":
  526. return "font/woff2";
  527. case "woff":
  528. return "font/woff";
  529. default:
  530. return null;
  531. }
  532. }
  533. private static void parse(string[] args, string summary, OptionEntry[]? extra) throws OptionError {
  534. var context = new OptionContext(summary);
  535. context.add_main_entries(global_options, null);
  536. if (extra != null) {
  537. context.add_main_entries((!)extra, null);
  538. }
  539. context.parse(ref args);
  540. }
  541. /** Drops `skip` positional words after `args[0]`, keeping `args[0]`. */
  542. private static string[] slice(string[] args, int skip) {
  543. var rest = new string[int.max(1, args.length - skip)];
  544. rest[0] = args[0];
  545. for (int i = skip + 1; i < args.length; i++) {
  546. rest[i - skip] = args[i];
  547. }
  548. return rest;
  549. }
  550. }
  551. }