stx-router 0.2.414 → 0.2.416

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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Does this request path ask for a file? True when its last segment ends in
3
+ * one of {@link FILE_REQUEST_EXTENSIONS} (all-lowercase or all-uppercase).
4
+ * Query strings and fragments are ignored; `/` and extensionless paths are
5
+ * never files.
6
+ */
7
+ export declare function isFileRequestPath(pathname: string): boolean;
8
+ /**
9
+ * Which request paths ask for a FILE rather than a page.
10
+ *
11
+ * An app whose routes start at the root has a dynamic page answering every
12
+ * one-segment path: `resources/views/[username].stx` renders a profile at
13
+ * `/chris`. Without a rule, it also answers `/favicon.ico`,
14
+ * `/apple-touch-icon.png`, `/robots.txt` on a site without one, and every
15
+ * `/wp-login.php` a scanner tries - each a full page render (often one that
16
+ * queries the database for a user called `favicon.ico`) shipped back as 80KB
17
+ * of HTML a browser expected to be an image.
18
+ *
19
+ * The rule: when the LAST segment of a request path ends in a known file
20
+ * extension, a dynamic page segment (`[param]`, `[[param]]`) never captures
21
+ * it. The request falls through to `publicDir`, and when nothing is there it
22
+ * gets a plain `404 Not Found` instead of a page.
23
+ *
24
+ * Why a list of known extensions rather than "anything after a dot":
25
+ *
26
+ * - Identifiers carry dots. A username `john.doe`, a repository `three.js`
27
+ * or `user.github.io`, a version `/docs/v1.2` or `/releases/1.0.0` are
28
+ * all pages. Only an extension that names a file format nobody would use
29
+ * as a name counts, and TLD-shaped ones (`.io`, `.dev`, `.sh`, `.me`,
30
+ * `.app`, `.ai`) are deliberately absent.
31
+ * - Page extensions stay routable: `.html`/`.htm` (the explicit spelling of
32
+ * a page), and `.md`/`.stx` (a docs page addressed by its source name).
33
+ * - Extensions are matched all-lowercase or all-uppercase (`.png`, `.PNG`),
34
+ * which is how files are actually named, and keeps the guard expressible
35
+ * in a case-sensitive route regex.
36
+ *
37
+ * Catch-alls (`[...path]`) are exempt on purpose. A catch-all is how an app
38
+ * says "the rest of the URL is mine, dots and all": a code browser serving
39
+ * `/owner/repo/tree/main/src/index.ts`, a docs site addressing `guide.md`.
40
+ * They still never shadow a file that really exists under `publicDir` (the
41
+ * serve path checks the disk first). That exemption is also the escape hatch:
42
+ * a segment that must accept file-shaped names (`[owner]/[repository]` for a
43
+ * repository called `three.js`) can be written as a catch-all.
44
+ *
45
+ * @module file-requests
46
+ */
47
+ /**
48
+ * File extensions that mark a request as a file, never a page. Lowercase; see
49
+ * {@link isFileRequestPath} for how case is handled.
50
+ */
51
+ export declare const FILE_REQUEST_EXTENSIONS: readonly string[];
52
+ /**
53
+ * Regex source for a lookahead that refuses a final path segment naming a
54
+ * file. Placed in front of a dynamic segment's capture group, so the route
55
+ * regex itself - the one the server matches with and ships to the client
56
+ * router as an owned route - carries the rule. Non-capturing: group indices
57
+ * are unchanged.
58
+ */
59
+ export declare const FILE_SEGMENT_GUARD: `(?![^/]*\\.(?:${alternation})$)`;
@@ -0,0 +1,99 @@
1
+ export const FILE_REQUEST_EXTENSIONS = Object.freeze([
2
+ "ico",
3
+ "png",
4
+ "jpg",
5
+ "jpeg",
6
+ "gif",
7
+ "webp",
8
+ "avif",
9
+ "svg",
10
+ "bmp",
11
+ "tif",
12
+ "tiff",
13
+ "heic",
14
+ "css",
15
+ "js",
16
+ "mjs",
17
+ "cjs",
18
+ "map",
19
+ "wasm",
20
+ "webmanifest",
21
+ "json",
22
+ "xml",
23
+ "txt",
24
+ "rss",
25
+ "atom",
26
+ "woff",
27
+ "woff2",
28
+ "ttf",
29
+ "otf",
30
+ "eot",
31
+ "mp3",
32
+ "mp4",
33
+ "m4a",
34
+ "m4v",
35
+ "mov",
36
+ "webm",
37
+ "ogg",
38
+ "ogv",
39
+ "oga",
40
+ "wav",
41
+ "flac",
42
+ "avi",
43
+ "mkv",
44
+ "pdf",
45
+ "csv",
46
+ "doc",
47
+ "docx",
48
+ "xls",
49
+ "xlsx",
50
+ "ppt",
51
+ "pptx",
52
+ "rtf",
53
+ "odt",
54
+ "zip",
55
+ "gz",
56
+ "tgz",
57
+ "tar",
58
+ "rar",
59
+ "7z",
60
+ "bz2",
61
+ "xz",
62
+ "php",
63
+ "phtml",
64
+ "asp",
65
+ "aspx",
66
+ "jsp",
67
+ "cgi",
68
+ "env",
69
+ "ini",
70
+ "yml",
71
+ "yaml",
72
+ "toml",
73
+ "sql",
74
+ "bak",
75
+ "old",
76
+ "swp",
77
+ "log",
78
+ "conf",
79
+ "cfg",
80
+ "exe",
81
+ "dmg",
82
+ "pkg",
83
+ "apk",
84
+ "ipa",
85
+ "msi",
86
+ "iso",
87
+ "bin",
88
+ "dll"
89
+ ]);
90
+ const alternation = FILE_REQUEST_EXTENSIONS.flatMap((ext) => ext === ext.toUpperCase() ? [ext] : [ext, ext.toUpperCase()]).join("|"), FILE_SEGMENT = new RegExp(`\\.(?:${alternation})$`);
91
+ export const FILE_SEGMENT_GUARD = `(?![^/]*\\.(?:${alternation})$)`;
92
+ export function isFileRequestPath(pathname) {
93
+ let path = pathname;
94
+ const cut = path.search(/[?#]/);
95
+ if (cut !== -1)
96
+ path = path.slice(0, cut);
97
+ const last = path.slice(path.lastIndexOf("/") + 1);
98
+ return last.length > 0 && FILE_SEGMENT.test(last);
99
+ }
@@ -65,7 +65,7 @@ export class Router {
65
65
  if (seenPatterns.has(identity))
66
66
  continue;
67
67
  seenPatterns.add(identity);
68
- const { regex, params } = patternToRegex(pattern), layouts = config.layouts !== !1 ? resolveLayoutChain(filePath, dir) : [];
68
+ const { regex, params } = patternToRegex(pattern, { page: !0 }), layouts = config.layouts !== !1 ? resolveLayoutChain(filePath, dir) : [];
69
69
  this.routes.push({
70
70
  pattern,
71
71
  regex,
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type { PatternToRegexOptions } from './matcher';
1
2
  export type { LayoutMetadata } from './layout-metadata';
2
3
  // Types
3
4
  export type {
@@ -24,6 +25,8 @@ export type {
24
25
  export { createRouter, findErrorPage, formatRoutes, Router } from './file-router';
25
26
  // Route pattern matching
26
27
  export { bracketPathToRegex, filePathToPattern, matchRoute, patternToRegex } from './matcher';
28
+ // Which request paths ask for a file, never a page
29
+ export { FILE_REQUEST_EXTENSIONS, FILE_SEGMENT_GUARD, isFileRequestPath } from './file-requests';
27
30
  // Named routes
28
31
  export { defineRoute, defineRoutes, resetRoutes, route, setAppUrl } from './named-routes';
29
32
  // Middleware system
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { createRouter, findErrorPage, formatRoutes, Router } from "./file-router.js";
2
2
  export { bracketPathToRegex, filePathToPattern, matchRoute, patternToRegex } from "./matcher.js";
3
+ export { FILE_REQUEST_EXTENSIONS, FILE_SEGMENT_GUARD, isFileRequestPath } from "./file-requests.js";
3
4
  export { defineRoute, defineRoutes, resetRoutes, route, setAppUrl } from "./named-routes.js";
4
5
  export {
5
6
  abortNavigation,
package/dist/matcher.d.ts CHANGED
@@ -1,21 +1,5 @@
1
1
  export declare function filePathToPattern(filePath: string, pagesDir: string): string;
2
- /**
3
- * Compile a route pattern into a regex and the parameter names its capture
4
- * groups correspond to.
5
- *
6
- * Single left-to-right pass, which is the whole point. Doing it as three
7
- * successive `.replace()` sweeps - optional, then catch-all, then required -
8
- * collects the names in *sweep* order while the capture groups end up in
9
- * *pattern* order, and the two disagree the moment one pattern mixes kinds.
10
- *
11
- * `/:owner/:repository/tree/:ref/:path*` collected as
12
- * `[path, owner, repository, ref]` against groups `(owner)(repository)(ref)(path)`,
13
- * so every value was bound to the wrong name: a repository browser given a URL
14
- * showed the root of a repository named after the branch. Nothing errored -
15
- * the regex matched and the page rendered - which is why it reads as a routing
16
- * mystery rather than an off-by-one.
17
- */
18
- export declare function patternToRegex(pattern: string): { regex: RegExp, params: string[] };
2
+ export declare function patternToRegex(pattern: string, options?: PatternToRegexOptions): { regex: RegExp, params: string[] };
19
3
  export declare function matchRoute(pathname: string, routes: { pattern: string, regex: RegExp, params: string[] }[]): { route: typeof routes[0], params: Record<string, string> } | null;
20
4
  /**
21
5
  * Compile a *file-style* route path - the one with `[param]` brackets - into a
@@ -35,4 +19,23 @@ export declare function matchRoute(pathname: string, routes: { pattern: string,
35
19
  *
36
20
  * Exported so nothing has to write those two lines again.
37
21
  */
38
- export declare function bracketPathToRegex(routePath: string): { regex: RegExp, params: string[] };
22
+ export declare function bracketPathToRegex(routePath: string, options?: PatternToRegexOptions): { regex: RegExp, params: string[] };
23
+ /**
24
+ * Compile a route pattern into a regex and the parameter names its capture
25
+ * groups correspond to.
26
+ *
27
+ * Single left-to-right pass, which is the whole point. Doing it as three
28
+ * successive `.replace()` sweeps - optional, then catch-all, then required -
29
+ * collects the names in *sweep* order while the capture groups end up in
30
+ * *pattern* order, and the two disagree the moment one pattern mixes kinds.
31
+ *
32
+ * `/:owner/:repository/tree/:ref/:path*` collected as
33
+ * `[path, owner, repository, ref]` against groups `(owner)(repository)(ref)(path)`,
34
+ * so every value was bound to the wrong name: a repository browser given a URL
35
+ * showed the root of a repository named after the branch. Nothing errored -
36
+ * the regex matched and the page rendered - which is why it reads as a routing
37
+ * mystery rather than an off-by-one.
38
+ */
39
+ export declare interface PatternToRegexOptions {
40
+ page?: boolean
41
+ }
package/dist/matcher.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { FILE_SEGMENT_GUARD } from "./file-requests.js";
2
3
  export function filePathToPattern(filePath, pagesDir) {
3
4
  let route = path.relative(pagesDir, filePath).replace(/\.(stx|md)$/, "");
4
5
  route = route.split(path.sep).join("/");
@@ -11,14 +12,15 @@ export function filePathToPattern(filePath, pagesDir) {
11
12
  route = route.replace(/:\.\.\.([^/]+)/g, ":$1*");
12
13
  return `/${route}`;
13
14
  }
14
- export function patternToRegex(pattern) {
15
- const params = [];
15
+ export function patternToRegex(pattern, options = {}) {
16
+ const guard = options.page ? FILE_SEGMENT_GUARD : "", params = [];
16
17
  let regexStr = "", i = 0;
17
18
  while (i < pattern.length) {
18
19
  const rest = pattern.slice(i), optional = /^\/:([^/?*]+)\?/.exec(rest);
19
20
  if (optional) {
20
21
  params.push(optional[1]);
21
- regexStr += "(?:/([^/]+))?";
22
+ const last = i + optional[0].length === pattern.length;
23
+ regexStr += `(?:/${last ? guard : ""}([^/]+))?`;
22
24
  i += optional[0].length;
23
25
  continue;
24
26
  }
@@ -32,7 +34,8 @@ export function patternToRegex(pattern) {
32
34
  const required = /^:([^/?*]+)/.exec(rest);
33
35
  if (required) {
34
36
  params.push(required[1]);
35
- regexStr += "([^/]+)";
37
+ const last = i + required[0].length === pattern.length;
38
+ regexStr += `${last ? guard : ""}([^/]+)`;
36
39
  i += required[0].length;
37
40
  continue;
38
41
  }
@@ -55,7 +58,7 @@ export function matchRoute(pathname, routes) {
55
58
  }
56
59
  return null;
57
60
  }
58
- export function bracketPathToRegex(routePath) {
61
+ export function bracketPathToRegex(routePath, options = {}) {
59
62
  const pattern = routePath.replace(/\[\[([^\]]+)\]\]/g, ":$1?").replace(/\[([^\]]+)\]/g, ":$1").replace(/:\.\.\.([^/]+)/g, ":$1*");
60
- return patternToRegex(pattern);
63
+ return patternToRegex(pattern, options);
61
64
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stx-router",
3
3
  "type": "module",
4
- "version": "0.2.414",
4
+ "version": "0.2.416",
5
5
  "description": "File-based router for STX — .stx template discovery, nested layouts, typed route params, middleware, and client-side SPA navigation.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",