spry.vala 26 KB

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