primate 0.26.4 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Primate
2
2
 
3
- Expressive, minimal and extensible web framework. To start [read guide].
3
+ Polymorphic development platform. To start [read guide].
4
4
 
5
5
  ## Resources
6
6
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "primate",
3
- "version": "0.26.4",
4
- "description": "Expressive, minimal and extensible web framework",
3
+ "version": "0.27.1",
4
+ "description": "Polymorphic development platform",
5
5
  "homepage": "https://primatejs.com",
6
6
  "bugs": "https://github.com/primatejs/primate/issues",
7
7
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "directory": "packages/primate"
19
19
  },
20
20
  "dependencies": {
21
- "rcompat": "^0.4.1"
21
+ "rcompat": "^0.5.0"
22
22
  },
23
23
  "engines": {
24
24
  "node": ">=18"
package/src/app.js CHANGED
@@ -2,7 +2,7 @@ import crypto from "rcompat/crypto";
2
2
  import { tryreturn } from "rcompat/async";
3
3
  import { Path } from "rcompat/fs";
4
4
  import { is } from "rcompat/invariant";
5
- import { transform, valmap } from "rcompat/object";
5
+ import { transform, valmap, to } from "rcompat/object";
6
6
  import { globify } from "rcompat/string";
7
7
  import * as runtime from "rcompat/meta";
8
8
 
@@ -43,6 +43,14 @@ const tags = {
43
43
  },
44
44
  };
45
45
 
46
+ const render_head = (assets, head) =>
47
+ to_sorted(assets, ({ type }) => -1 * (type === "importmap"))
48
+ .map(({ src, code, type, inline, integrity }) =>
49
+ type === "style"
50
+ ? tags.style({ inline, code, href: src })
51
+ : tags.script({ inline, code, type, integrity, src }),
52
+ ).join("\n").concat("\n", head ?? "");
53
+
46
54
  const { name, version } = await new Path(import.meta.url).up(2)
47
55
  .join(runtime.manifest).json();
48
56
 
@@ -138,19 +146,13 @@ export default async (log, root, config) => {
138
146
  runpath(...directories) {
139
147
  return this.path.build.join(...directories);
140
148
  },
141
- async render({ body = "", head = "", page = config.pages.index } = {}) {
142
- const { location: { pages } } = this.config;
143
-
144
- const html = await index(this.runpath(pages), page, config.pages.index);
145
-
146
- const heads = to_sorted(this.assets,
147
- ({ type }) => -1 * (type === "importmap"))
148
- .map(({ src, code, type, inline, integrity }) =>
149
- type === "style"
150
- ? tags.style({ inline, code, href: src })
151
- : tags.script({ inline, code, type, integrity, src }),
152
- ).join("\n").concat("\n", head);
153
- return html.replace("%body%", _ => body).replace("%head%", _ => heads);
149
+ async render({ body, head }, page = config.pages.index, placeholders = {}) {
150
+ const { assets, config: { location, pages } } = this;
151
+
152
+ return to({ ...placeholders, body, head: render_head(assets, head) })
153
+ .reduce((html, [key, value]) => html.replace(`%${key}%`, value ?? ""),
154
+ await index(this.runpath(location.pages), page, pages.index))
155
+ .replaceAll(/%.*%/gus, "");
154
156
  },
155
157
  async inline(code, type) {
156
158
  const integrity = await this.hash(code);
package/src/dispatch.js CHANGED
@@ -17,7 +17,9 @@ export default (patches = {}) => (object, raw, cased = true) => {
17
17
 
18
18
  return object[cased ? property : property.toLowerCase()];
19
19
  },
20
- all: () => object,
20
+ toString() {
21
+ return JSON.stringify(object);
22
+ },
21
23
  raw,
22
24
  });
23
25
  };
package/src/errors.json CHANGED
@@ -51,11 +51,6 @@
51
51
  "fix": "use only latin letters and decimal digits in path parameters",
52
52
  "level": "Error"
53
53
  },
54
- "InvalidRouteName": {
55
- "message": "invalid route name {0}",
56
- "fix": "do not use dots in route names",
57
- "level": "Error"
58
- },
59
54
  "InvalidTypeExport": {
60
55
  "message": "invalid type export at {0}",
61
56
  "fix": "export object with a `base` string and a `validate` function",
@@ -96,14 +91,9 @@
96
91
  "fix": "add configuration options or remove file",
97
92
  "level": "Warn"
98
93
  },
99
- "NoFileForPath": {
100
- "message": "no file for {0}",
101
- "fix": "create a file at {1}{0}",
102
- "level": "Info"
103
- },
104
94
  "NoHandlerForExtension": {
105
95
  "message": "no handler for {0} extension",
106
- "fix": "add handler module for .{0} files or remove {1}",
96
+ "fix": "add handler module for {0} files or remove {1}",
107
97
  "level": "Error"
108
98
  },
109
99
  "NoRouteToPath": {
@@ -1,9 +1,8 @@
1
1
  import { Response, Status, MediaType } from "rcompat/http";
2
2
 
3
3
  export default (body = "Not Found", { status = Status.NOT_FOUND, page } = {}) =>
4
- async app => new Response(await app.render({
5
- body,
6
- page: page ?? app.config.pages.error }), {
4
+ async app =>
5
+ new Response(await app.render({ body }, page ?? app.config.pages.error), {
7
6
  status,
8
7
  headers: { ...app.headers(), "Content-Type": MediaType.TEXT_HTML },
9
8
  });
@@ -4,26 +4,25 @@ const script_re = /(?<=<script)>(?<code>.*?)(?=<\/script>)/gus;
4
4
  const style_re = /(?<=<style)>(?<code>.*?)(?=<\/style>)/gus;
5
5
  const remove = /<(?<tag>script|style)>.*?<\/\k<tag>>/gus;
6
6
 
7
- export default (name, options = {}) => {
8
- const { status = Status.OK, partial = false } = options;
7
+ const render = (body, head, { partial = false, app, page, placeholders }) =>
8
+ partial ? body : app.render({ body, head }, page, placeholders);
9
9
 
10
- return async app => {
11
- const html = await app.path.components.join(name).text();
12
- const scripts = await Promise.all([...html.matchAll(script_re)]
13
- .map(({ groups: { code } }) => app.inline(code, "module")));
14
- const styles = await Promise.all([...html.matchAll(style_re)]
15
- .map(({ groups: { code } }) => app.inline(code, "style")));
16
- const assets = [...scripts, ...styles];
10
+ export default (name, options = {}) => async app => {
11
+ const component = await app.path.components.join(name).text();
12
+ const scripts = await Promise.all([...component.matchAll(script_re)]
13
+ .map(({ groups: { code } }) => app.inline(code, "module")));
14
+ const styles = await Promise.all([...component.matchAll(style_re)]
15
+ .map(({ groups: { code } }) => app.inline(code, "style")));
16
+ const assets = [...scripts, ...styles];
17
17
 
18
- const body = html.replaceAll(remove, _ => "");
19
- const head = assets.map(asset => asset.head).join("\n");
20
- const script = scripts.map(asset => asset.csp).join(" ");
21
- const style = styles.map(asset => asset.csp).join(" ");
22
- const headers = { script, style };
18
+ const body = component.replaceAll(remove, _ => "");
19
+ const head = assets.map(asset => asset.head).join("\n");
20
+ const script = scripts.map(asset => asset.csp).join(" ");
21
+ const style = styles.map(asset => asset.csp).join(" ");
22
+ const headers = { script, style };
23
23
 
24
- return new Response(partial ? body : await app.render({ body, head }), {
25
- status,
24
+ return new Response(await render(body, head, { app, ...options }), {
25
+ status: options.status ?? Status.OK,
26
26
  headers: { ...app.headers(headers), "Content-Type": MediaType.TEXT_HTML },
27
27
  });
28
- };
29
28
  };
@@ -2,6 +2,6 @@ import errors from "../errors.js";
2
2
 
3
3
  export default (name, props, options) => async (app, ...rest) => {
4
4
  const extension = name.slice(name.lastIndexOf("."));
5
- return app.handlers[extension]?.(name, props, options)(app, ...rest)
5
+ return app.extensions[extension]?.handle(name, props, options)(app, ...rest)
6
6
  ?? errors.NoHandlerForExtension.throw(extension, name);
7
7
  };
@@ -1,25 +1,21 @@
1
1
  import { Response, Status, MediaType } from "rcompat/http";
2
2
  import { cascade, tryreturn } from "rcompat/async";
3
3
  import { respond } from "./respond/exports.js";
4
- import { invalid } from "./route.js";
5
4
  import { error as clientError } from "../handlers/exports.js";
6
- import _errors from "../errors.js";
7
- const { NoFileForPath } = _errors;
8
5
 
9
6
  const guard_error = Symbol("guard_error");
10
7
  const guard = (app, guards) => async (request, next) => {
11
8
  // handle guards
12
9
  try {
13
- guards.every(guard => {
14
- const result = guard(request);
15
- if (result === true) {
16
- return true;
10
+ for (const guard of guards) {
11
+ const result = await guard(request);
12
+ if (result !== true) {
13
+ const error = new Error();
14
+ error.result = result;
15
+ error.type = guard_error;
16
+ throw error;
17
17
  }
18
- const error = new Error();
19
- error.result = result;
20
- error.type = guard_error;
21
- throw error;
22
- });
18
+ }
23
19
 
24
20
  return next(request);
25
21
  } catch (error) {
@@ -31,18 +27,23 @@ const guard = (app, guards) => async (request, next) => {
31
27
  }
32
28
  };
33
29
 
30
+ const get_layouts = async (layouts, request) => {
31
+ const stop_at = layouts.findIndex(({ recursive }) => recursive === false);
32
+ return Promise.all(layouts
33
+ .slice(stop_at === -1 ? 0 : stop_at)
34
+ .map(layout => layout.default(request)));
35
+ };
36
+
34
37
  export default app => {
35
38
  const { config: { http: { static: { root } }, location } } = app;
39
+ const route = request => app.route(request);
36
40
 
37
41
  const as_route = async request => {
38
- const { pathname } = request.url;
39
- // if NoFileForPath is thrown, this will remain undefined
42
+ // if tryreturn throws, this will default
40
43
  let error_handler = app.error.default;
41
44
 
42
45
  return tryreturn(async _ => {
43
- const { path, guards, errors, layouts, handler } = invalid(pathname)
44
- ? NoFileForPath.throw(pathname, location.static)
45
- : await app.route(request);
46
+ const { path, guards, errors, layouts, handler } = await route(request);
46
47
  error_handler = errors?.at(-1);
47
48
 
48
49
  const pathed = { ...request, path };
@@ -51,9 +52,8 @@ export default app => {
51
52
 
52
53
  // handle request
53
54
  const response = (await cascade(hooks, handler))(pathed);
54
- return (await respond(await response))(app, {
55
- layouts: await Promise.all(layouts.map(layout => layout(request))),
56
- }, pathed);
55
+ const $layouts = { layouts: await get_layouts(layouts, request) };
56
+ return (await respond(await response))(app, $layouts, pathed);
57
57
  }).orelse(async error => {
58
58
  app.log.auto(error);
59
59
 
@@ -1,34 +1,16 @@
1
- import { URL, MediaType } from "rcompat/http";
2
- import { tryreturn } from "rcompat/sync";
3
- import { stringify } from "rcompat/streams";
1
+ import { URL, Body } from "rcompat/http";
4
2
  import { from, valmap } from "rcompat/object";
5
- import errors from "../errors.js";
6
3
 
7
- const { APPLICATION_FORM_URLENCODED, APPLICATION_JSON } = MediaType;
8
-
9
- const { decodeURIComponent: decode } = globalThis;
10
4
  const deslash = url => url.replaceAll(/(?<!http:)\/{2,}/gu, _ => "/");
11
5
 
12
- const contents = {
13
- [APPLICATION_FORM_URLENCODED]: body => from(body.split("&")
14
- .map(part => part.split("=")
15
- .map(subpart => decode(subpart).replaceAll("+", " ")))),
16
- [APPLICATION_JSON]: body => JSON.parse(body),
17
- };
18
-
19
- const content = (type, body) =>
20
- tryreturn(_ => contents[type?.split(";")[0]]?.(body) ?? body)
21
- .orelse(_ => errors.CannotParseBody.throw(body, type));
22
-
23
6
  export default dispatch => async original => {
24
- const { headers } = original;
25
- const url = new URL(deslash(decode(original.url)));
26
- const body = await stringify(original.body);
7
+ const { body, headers } = original;
8
+ const url = new URL(deslash(globalThis.decodeURIComponent(original.url)));
27
9
  const cookies = headers.get("cookie");
28
10
 
29
11
  return { original, url,
12
+ body: await Body.parse(body, headers.get("content-type")) ?? {},
30
13
  ...valmap({
31
- body: [content(headers.get("content-type"), body), body],
32
14
  query: [from(url.searchParams), url.search],
33
15
  headers: [from(headers), headers, false],
34
16
  cookies: [from(cookies?.split(";").map(cookie => cookie.trim().split("="))
@@ -6,9 +6,6 @@ import validate from "../validate.js";
6
6
  // insensitive-case equal
7
7
  const ieq = (left, right) => left.toLowerCase() === right.toLowerCase();
8
8
 
9
- /* routes may not contain dots */
10
- export const invalid = route => /\./u.test(route);
11
-
12
9
  const deroot = pathname => pathname.endsWith("/") && pathname !== "/"
13
10
  ? pathname.slice(0, -1) : pathname;
14
11
 
@@ -21,7 +21,7 @@ export default async ({
21
21
  .filter(filter)
22
22
  .map(async path => [
23
23
  `${path}`.replace(directory, _ => "").slice(1, -ending.length),
24
- (await import(path)).default,
24
+ (await import(path)),
25
25
  ]));
26
26
  warn && await Path.exists(directory) && empty(log)(objects, name, directory);
27
27
 
@@ -11,7 +11,7 @@ export default type => async (log, directory, load) => {
11
11
  ([a], [b]) => a.length - b.length);
12
12
 
13
13
  const resolve = name => new Path(directory, name, `+${type}.js`);
14
- objects.some(([name, value]) => typeof value !== "function"
14
+ objects.some(([name, value]) => typeof value.default !== "function"
15
15
  && errors.InvalidDefaultExport.throw(resolve(name)));
16
16
 
17
17
  return objects;
@@ -3,7 +3,6 @@ import { from } from "rcompat/object";
3
3
  import { default as fs, doubled } from "./common.js";
4
4
  import * as get from "./routes/exports.js";
5
5
  import errors from "../errors.js";
6
- import { invalid } from "../hooks/route.js";
7
6
 
8
7
  const make = path => {
9
8
  const double = doubled(path.split("/")
@@ -18,8 +17,6 @@ const make = path => {
18
17
  return `(?<${param}>[^/]{1,}?)`;
19
18
  }).orelse(_ => errors.InvalidPathParameter.throw(named, path)));
20
19
 
21
- invalid(route) && errors.InvalidRouteName.throw(path);
22
-
23
20
  return new RegExp(`^/${route}$`, "u");
24
21
  };
25
22
 
@@ -31,18 +28,19 @@ export default async (app, load = fs) => {
31
28
  .map(async extra => [extra, await get[extra](log, directory, load)])));
32
29
 
33
30
  return (await get.routes(log, directory, load)).map(([path, imported]) => {
34
- if (imported === undefined || Object.keys(imported).length === 0) {
31
+ const route = imported.default;
32
+ if (route === undefined || Object.keys(route).length === 0) {
35
33
  errors.EmptyRouteFile.warn(log, directory.join(`${path}.js`).path);
36
34
  return [];
37
35
  }
38
36
  const filtered = filter(path);
39
37
 
40
- return Object.entries(imported).map(([method, handler]) => ({
38
+ return Object.entries(route).map(([method, handler]) => ({
41
39
  method,
42
40
  handler,
43
41
  pathname: make(path.endsWith("/") ? path.slice(0, -1) : path),
44
- guards: routes.guards.filter(filtered).map(([, guard]) => guard),
45
- errors: routes.errors.filter(filtered).map(([, error]) => error),
42
+ guards: routes.guards.filter(filtered).map(([, guard]) => guard.default),
43
+ errors: routes.errors.filter(filtered).map(([, error]) => error.default),
46
44
  layouts: routes.layouts.filter(filtered).map(([, layout]) => layout),
47
45
  }));
48
46
  }).flat();
@@ -6,8 +6,9 @@ import fs from "./common.js";
6
6
 
7
7
  const filter = path => /^[a-z]/u.test(path.name);
8
8
 
9
- export default async (log, directory, load = fs) => {
10
- const types = await fs({ log, directory, name: "types", filter });
9
+ export default async (log, directory) => {
10
+ const types = (await fs({ log, directory, name: "types", filter }))
11
+ .map(([name, type]) => [name, type.default]);
11
12
 
12
13
  const resolve = name => new Path(directory, name);
13
14
  types.every(([name, type]) => tryreturn(_ => {