astro-dev-edit 0.11.0

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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/package.json +52 -0
  4. package/src/client/admin-bar.ts +622 -0
  5. package/src/client/api.ts +370 -0
  6. package/src/client/classify-cache.ts +61 -0
  7. package/src/client/css-inspect.ts +345 -0
  8. package/src/client/editors/asset-picker.ts +155 -0
  9. package/src/client/editors/body-editor.ts +419 -0
  10. package/src/client/editors/collections-panel.ts +1532 -0
  11. package/src/client/editors/copy-panel.ts +73 -0
  12. package/src/client/editors/drawer.ts +95 -0
  13. package/src/client/editors/entry.ts +433 -0
  14. package/src/client/editors/expression.ts +77 -0
  15. package/src/client/editors/fields.ts +309 -0
  16. package/src/client/editors/image.ts +268 -0
  17. package/src/client/editors/markup-insert.ts +73 -0
  18. package/src/client/editors/markup.ts +125 -0
  19. package/src/client/editors/media-grid.ts +326 -0
  20. package/src/client/editors/media-modal.ts +588 -0
  21. package/src/client/editors/notice.ts +160 -0
  22. package/src/client/editors/peek.ts +135 -0
  23. package/src/client/editors/settings-panel.ts +457 -0
  24. package/src/client/editors/source-popup.ts +166 -0
  25. package/src/client/editors/text.ts +105 -0
  26. package/src/client/editors/unsplash-pane.ts +317 -0
  27. package/src/client/element-context.ts +308 -0
  28. package/src/client/features.ts +81 -0
  29. package/src/client/focus.ts +166 -0
  30. package/src/client/group.ts +186 -0
  31. package/src/client/highlight.ts +146 -0
  32. package/src/client/hover.ts +485 -0
  33. package/src/client/icons.ts +160 -0
  34. package/src/client/markdown.ts +319 -0
  35. package/src/client/overlay.ts +466 -0
  36. package/src/client/page-source.ts +143 -0
  37. package/src/client/router.ts +198 -0
  38. package/src/client/shadow.ts +111 -0
  39. package/src/client/source-map.ts +150 -0
  40. package/src/client/state.ts +153 -0
  41. package/src/client/styles.ts +3485 -0
  42. package/src/client/tree-model.ts +45 -0
  43. package/src/client/tree.ts +366 -0
  44. package/src/client/ui.ts +987 -0
  45. package/src/client/unsplash-search.ts +250 -0
  46. package/src/index.ts +299 -0
  47. package/src/patcher/astro.ts +792 -0
  48. package/src/patcher/content-config.ts +1035 -0
  49. package/src/patcher/dotenv.ts +121 -0
  50. package/src/patcher/expression-trace.ts +326 -0
  51. package/src/patcher/frontmatter.ts +249 -0
  52. package/src/patcher/registry.ts +11 -0
  53. package/src/patcher/types.ts +32 -0
  54. package/src/server/annotate.ts +173 -0
  55. package/src/server/assets.ts +167 -0
  56. package/src/server/collection-entries.ts +91 -0
  57. package/src/server/content-config.ts +210 -0
  58. package/src/server/editor.ts +15 -0
  59. package/src/server/entry-detect.ts +110 -0
  60. package/src/server/entry-resolve-routes.ts +218 -0
  61. package/src/server/entry-routes.ts +304 -0
  62. package/src/server/inspect-locate.ts +81 -0
  63. package/src/server/inspect-routes.ts +94 -0
  64. package/src/server/middleware.ts +480 -0
  65. package/src/server/options.ts +778 -0
  66. package/src/server/page-source-routes.ts +71 -0
  67. package/src/server/paths.ts +219 -0
  68. package/src/server/private-files.ts +116 -0
  69. package/src/server/route-manifest.ts +200 -0
  70. package/src/server/router.ts +94 -0
  71. package/src/server/schema-introspect.ts +233 -0
  72. package/src/server/schema-routes.ts +808 -0
  73. package/src/server/settings-routes.ts +246 -0
  74. package/src/server/settings.ts +382 -0
  75. package/src/server/text-writes.ts +105 -0
  76. package/src/server/unsplash-routes.ts +515 -0
  77. package/src/server/zod-adapt.ts +239 -0
  78. package/src/shared/asset-path.ts +132 -0
  79. package/src/shared/protocol.ts +935 -0
  80. package/src/shared/slug.ts +17 -0
  81. package/src/shared/unsplash.ts +51 -0
@@ -0,0 +1,71 @@
1
+ import type { AstroIntegrationLogger } from 'astro';
2
+ import type { PageSourceRequest, PageSourceResponse } from '../shared/protocol.ts';
3
+ import type { OptionsResolver } from './options.ts';
4
+ import type { RouteManifest } from './route-manifest.ts';
5
+ import type { Route } from './router.ts';
6
+
7
+ /**
8
+ * The page-source route group (/page-source) — "which file is the page I am
9
+ * looking at written in?", for the admin bar's *Open page source*.
10
+ *
11
+ * Its own module rather than a core route because the core table is the
12
+ * loc-based editing flow: every route there takes a `SourceLoc` or an asset,
13
+ * while this one takes a **pathname** and carries its own injected capability
14
+ * (`route-manifest.ts`). The shape is `/inspect/open`'s — one route, one
15
+ * feature, sharing the `openInEditor` gate.
16
+ *
17
+ * **Resolve-only: it opens nothing.** `/open` stays the single editor-launch
18
+ * path behind `validateEditablePath`, which keeps the matching algorithm
19
+ * testable in vitest instead of spawning the developer's editor on every
20
+ * assertion. Read-only — no writes ever pass through here.
21
+ */
22
+
23
+ export interface PageSourceRouteDeps {
24
+ logger: AstroIntegrationLogger;
25
+ /** Live options — `openInEditor` gates the group and can change without a
26
+ * dev-server restart. */
27
+ optionsResolver: OptionsResolver;
28
+ /** Astro's route manifest, or null when there is none (a test, or an Astro
29
+ * that never fired the routes hook) — answered as a refusal, not a guess. */
30
+ routeManifest: RouteManifest | null;
31
+ }
32
+
33
+ export function createPageSourceRoutes(deps: PageSourceRouteDeps): Route[] {
34
+ const { logger, optionsResolver, routeManifest } = deps;
35
+
36
+ return [
37
+ // Resolve a browser pathname to the source file of the route serving it.
38
+ {
39
+ method: 'POST',
40
+ path: '/page-source',
41
+ maxBytes: 64 * 1024,
42
+ label: 'page-source',
43
+ handler: async (body) => {
44
+ const { options } = await optionsResolver.resolve();
45
+ if (!options.openInEditor) {
46
+ return { status: 403, body: { error: 'open-in-editor is disabled by configuration' } };
47
+ }
48
+ const { pathname } = body as PageSourceRequest;
49
+ if (typeof pathname !== 'string' || pathname === '') {
50
+ throw new Error('pathname is required');
51
+ }
52
+
53
+ const hit = routeManifest?.forPathname(pathname) ?? {
54
+ ok: false as const,
55
+ refusal: 'no-routes' as const,
56
+ };
57
+ if (!hit.ok) {
58
+ // The one diagnostic anyone wants when this misfires. debug, not
59
+ // info: a menu click should not print to the dev server log.
60
+ logger.debug(`page-source: ${pathname} → ${hit.refusal}`);
61
+ const miss: PageSourceResponse = { file: null, pattern: null, refusal: hit.refusal };
62
+ // A miss is an answer, not an error — the panel says why nothing
63
+ // opened, the same way /classify reports a non-editable element.
64
+ return { status: 200, body: miss };
65
+ }
66
+ const found: PageSourceResponse = { file: hit.file, pattern: hit.pattern, refusal: null };
67
+ return { status: 200, body: found };
68
+ },
69
+ },
70
+ ];
71
+ }
@@ -0,0 +1,219 @@
1
+ import { chmod, realpath, rename, writeFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
3
+
4
+ /**
5
+ * Path confinement and mapping helpers — the single home for every "may this
6
+ * path be touched?" rule. (spec §8)
7
+ */
8
+
9
+ /** True when `abs` lies inside `root` (string-space; no symlink resolution). */
10
+ export function insideRoot(root: string, abs: string): boolean {
11
+ const rel = relative(root, abs);
12
+ return !rel.startsWith('..') && !rel.startsWith(sep);
13
+ }
14
+
15
+ /**
16
+ * Resolve a client-requested upload directory, or fall back.
17
+ *
18
+ * Uploads may only land in directories the integration already treats as asset
19
+ * locations — otherwise a client-supplied `targetDir` would be a general
20
+ * "write a file anywhere under the project root" capability. Anything outside
21
+ * them (or escaping the root) returns `fallback` rather than throwing, so a
22
+ * stale or hostile request writes somewhere safe instead of failing the upload.
23
+ * Pure string-space, like the rest of this module. (spec §8)
24
+ */
25
+ export function resolveUploadDir(
26
+ root: string,
27
+ allowedDirs: string[],
28
+ fallback: string,
29
+ requested?: string,
30
+ ): string {
31
+ if (!requested) return fallback;
32
+ const abs = resolve(root, requested);
33
+ if (!insideRoot(root, abs)) return fallback;
34
+ const allowed = allowedDirs.some((dir) => {
35
+ const dirAbs = resolve(root, dir);
36
+ return insideRoot(root, dirAbs) && insideRoot(dirAbs, abs);
37
+ });
38
+ return allowed ? requested : fallback;
39
+ }
40
+
41
+ /**
42
+ * Where an asset write should land — the shared rule behind `/upload` and
43
+ * `/unsplash/import`.
44
+ *
45
+ * Two decisions in one place. An `assetRef: 'relative'` field's asset is
46
+ * imported by Astro rather than served verbatim, so it falls back to the
47
+ * src-side dir instead of the web-servable one; and a client-supplied
48
+ * `targetDir` is honoured only when {@link resolveUploadDir} finds it inside a
49
+ * configured asset directory. Extracted rather than duplicated because a drift
50
+ * between two copies of this is a path-confinement bug — the exact class this
51
+ * module exists to centralise.
52
+ *
53
+ * `redirected` is true when a requested `targetDir` was refused, so the caller
54
+ * can log it: the rule lives here, the logger does not.
55
+ */
56
+ export function resolveAssetTarget(
57
+ root: string,
58
+ dirs: { uploadDir: string; imageUploadDir: string; allowedDirs: string[] },
59
+ req: { assetRef?: 'relative'; targetDir?: string },
60
+ ): { dir: string; redirected: boolean } {
61
+ const fallback = req.assetRef === 'relative' ? dirs.imageUploadDir : dirs.uploadDir;
62
+ const dir = resolveUploadDir(root, dirs.allowedDirs, fallback, req.targetDir);
63
+ return { dir, redirected: Boolean(req.targetDir) && dir !== req.targetDir };
64
+ }
65
+
66
+ /** Root-relative path of a file, in posix form regardless of platform. */
67
+ function relPosix(root: string, absFile: string): string {
68
+ return relative(root, absFile).split(sep).join('/');
69
+ }
70
+
71
+ /**
72
+ * The project's public directory as a root-relative posix prefix, with no
73
+ * trailing slash. Astro's `publicDir` is configurable (and reaches us as a
74
+ * resolved fs path), so every rule below is written against this rather than a
75
+ * literal `public/` — a project on `publicDir: 'static'` is otherwise told its
76
+ * served files live at a URL the site does not have.
77
+ */
78
+ function publicPrefix(root: string, publicDir: string): string {
79
+ return relPosix(root, resolve(root, publicDir)).replace(/\/+$/, '');
80
+ }
81
+
82
+ /**
83
+ * Whether the **built** site will serve `absFile` at its {@link toWebPath} URL.
84
+ *
85
+ * Only the public directory is copied verbatim into the output. Everything else
86
+ * under the project root — `src/assets` above all — is served by Vite in dev and
87
+ * simply absent from a build, so a path pointing at it is a dev-only URL. That
88
+ * distinction is a property of the file, not something each call site can be
89
+ * trusted to re-derive; it travels to the client on `AssetInfo.servable`.
90
+ */
91
+ export function isServableAsset(root: string, absFile: string, publicDir = 'public'): boolean {
92
+ const rel = relPosix(root, absFile);
93
+ if (rel.startsWith('../') || rel === '..') return false;
94
+ const prefix = publicPrefix(root, publicDir);
95
+ // A publicDir that *is* the root makes everything under it public; one that
96
+ // escapes the root can serve nothing.
97
+ if (prefix === '') return true;
98
+ if (prefix.startsWith('../')) return false;
99
+ return rel.startsWith(prefix + '/');
100
+ }
101
+
102
+ /** Map an absolute file under the project root to the path it is served at:
103
+ * the public directory maps to the site root; everything else keeps its
104
+ * project path (truthful in dev, and {@link isServableAsset} is what says so). */
105
+ export function toWebPath(root: string, absFile: string, publicDir = 'public'): string {
106
+ const rel = relPosix(root, absFile);
107
+ const prefix = publicPrefix(root, publicDir);
108
+ return '/' + (prefix && rel.startsWith(prefix + '/') ? rel.slice(prefix.length + 1) : rel);
109
+ }
110
+
111
+ /** Why a path was refused. `outside-roots` is a *normal answer* for read-only
112
+ * callers — the element belongs to a package or a non-content file, which is
113
+ * information, not a fault. The others are anomalies worth surfacing. */
114
+ export type PathRefusal = 'missing' | 'escapes-root' | 'outside-roots' | 'bad-extension';
115
+
116
+ export type PathCheck =
117
+ | { ok: true; abs: string }
118
+ /** `abs` is present whenever the path resolved at all (absent only for
119
+ * `missing`), so callers can tailor a message from where it landed. */
120
+ | { ok: false; code: PathRefusal; reason: string; abs?: string };
121
+
122
+ /** True when the path lives inside an installed package. Such files are real
123
+ * and readable but are never the user's own source — `astro:assets` renders
124
+ * every `<Image>` through `node_modules/astro/components/Image.astro`, and
125
+ * that is the path the source annotation carries. */
126
+ export function isPackageOwned(abs: string): boolean {
127
+ return abs.split(sep).includes('node_modules');
128
+ }
129
+
130
+ /**
131
+ * Resolve and check a client-supplied source path, *without* throwing. The
132
+ * real path (symlinks resolved) must live inside the project root AND inside
133
+ * one of the configured content roots, with an allowed extension.
134
+ *
135
+ * This is the single implementation of the gate; `validateEditablePath` is the
136
+ * throwing wrapper over it. Read-only callers that want to answer "not
137
+ * editable" instead of erroring use this directly. (spec §8)
138
+ */
139
+ export async function checkEditablePath(
140
+ root: string,
141
+ contentRoots: string[],
142
+ editableExtensions: string[],
143
+ file: string,
144
+ ): Promise<PathCheck> {
145
+ let abs: string;
146
+ try {
147
+ abs = await realpath(resolve(root, file));
148
+ } catch {
149
+ return { ok: false, code: 'missing', reason: `no such file: ${file}` };
150
+ }
151
+ const rootReal = await realpath(root);
152
+ const rel = relative(rootReal, abs);
153
+ if (!rel || rel.startsWith('..') || rel.startsWith(sep)) {
154
+ return { ok: false, code: 'escapes-root', reason: 'path escapes the project root', abs };
155
+ }
156
+ const inContentRoot = contentRoots.some(
157
+ (cr) => rel === cr || rel.startsWith(cr.endsWith(sep) ? cr : cr + sep),
158
+ );
159
+ if (!inContentRoot) {
160
+ return {
161
+ ok: false,
162
+ code: 'outside-roots',
163
+ reason: 'path is outside the editable content roots',
164
+ abs,
165
+ };
166
+ }
167
+ if (!editableExtensions.includes(extname(abs).toLowerCase())) {
168
+ return {
169
+ ok: false,
170
+ code: 'bad-extension',
171
+ reason: `files of type ${extname(abs) || '(none)'} are not editable`,
172
+ abs,
173
+ };
174
+ }
175
+ return { ok: true, abs };
176
+ }
177
+
178
+ /**
179
+ * Throwing form of {@link checkEditablePath} — the gate every *writing* or
180
+ * file-launching route passes through. Unchanged in behavior: any refusal is
181
+ * an Error. (spec §8)
182
+ */
183
+ export async function validateEditablePath(
184
+ root: string,
185
+ contentRoots: string[],
186
+ editableExtensions: string[],
187
+ file: string,
188
+ ): Promise<string> {
189
+ const check = await checkEditablePath(root, contentRoots, editableExtensions, file);
190
+ if (!check.ok) throw new Error(check.reason);
191
+ return check.abs;
192
+ }
193
+
194
+ /** Owner-only. The mode for any file holding the Unsplash access key. */
195
+ export const SECRET_MODE = 0o600;
196
+
197
+ /**
198
+ * Write atomically: temp file in the same directory, then rename. (spec §10)
199
+ *
200
+ * `mode` rides the **temp file**, not the finished one. Chmod-ing after the
201
+ * rename leaves a window in which the content — for a secret-bearing file, the
202
+ * access key — sits on disk at the process umask, typically world-readable.
203
+ * `rename` then carries the temp inode's mode onto the target, so a
204
+ * pre-existing loose file is tightened rather than left as it was. The chmod is
205
+ * belt-and-braces over `writeFile`'s `mode`, which is honoured only on create:
206
+ * a crashed run can leave a temp file behind for this one to reuse.
207
+ */
208
+ export async function atomicWrite(target: string, content: string, mode?: number): Promise<void> {
209
+ const tmp = join(dirname(target), `.${basename(target)}.dev-edit-tmp-${process.pid}`);
210
+ await writeFile(tmp, content, mode === undefined ? 'utf8' : { encoding: 'utf8', mode });
211
+ if (mode !== undefined) {
212
+ try {
213
+ await chmod(tmp, mode);
214
+ } catch {
215
+ // Non-POSIX filesystem; the content is written either way.
216
+ }
217
+ }
218
+ await rename(tmp, target);
219
+ }
@@ -0,0 +1,116 @@
1
+ import type { Connect, Plugin as VitePlugin } from 'vite';
2
+
3
+ /**
4
+ * Files this integration writes into the project root that the dev server must
5
+ * never hand back over HTTP.
6
+ *
7
+ * Vite serves the project root, so `.astro-dev-edit.json` — the option and
8
+ * field-override document `settings.ts` writes — is reachable at
9
+ * `GET /.astro-dev-edit.json` and at `/@fs/<root>/.astro-dev-edit.json`. None of
10
+ * this integration's own defences see those requests: `middleware.ts` is mounted
11
+ * from `astro:server:setup` with `server.middlewares.use()`, which appends
12
+ * *after* Vite's static and `@fs` handlers, and it only inspects URLs under
13
+ * `/__dev-edit` before calling `next()`. So `isLocalRequest` and
14
+ * `validateEditablePath` are both bypassed. (spec §8)
15
+ *
16
+ * **Why not `server.fs.deny`.** That is the obvious lever and it is a trap.
17
+ * Vite resolves server options with `mergeWithDefaultsRecursively`, where an
18
+ * array in user config *replaces* the default rather than extending it — so
19
+ * adding one entry would silently drop Vite's own protection for `.env`,
20
+ * `.env.*`, `*.{crt,pem,key,…}`, `.npmrc`, `.yarnrc.yml` and every `.git`
21
+ * directory. Nor can the list be extended after the fact: `fsDenyGlob` is compiled during
22
+ * `resolveConfig`, before any `configResolved` hook could push to it.
23
+ *
24
+ * **The seam.** A Vite plugin's `configureServer` *body* runs before Vite
25
+ * installs its own middlewares; only the function it returns is a post hook. So
26
+ * registering here — and only here — puts this guard ahead of the static and
27
+ * `@fs` handlers, on every Vite version Astro 5–7 pins.
28
+ */
29
+
30
+ /** Fixed filenames, never client-supplied. Add a file here to make it unservable. */
31
+ const PRIVATE_FILES: readonly string[] = ['.astro-dev-edit.json'];
32
+
33
+ /**
34
+ * `atomicWrite`'s mid-write sibling, `.<basename>.dev-edit-tmp-<pid>`
35
+ * (`paths.ts`). It is covered for two reasons: the sibling of a secret-bearing
36
+ * file briefly holds that secret, and for a dotfile target the doubled leading
37
+ * dot (`..env.local.dev-edit-tmp-1234`) falls outside Vite's own `.env.*` deny.
38
+ */
39
+ const TMP_SEGMENT_RE = /^\..+\.dev-edit-tmp-\d+$/;
40
+
41
+ /**
42
+ * Whether a request URL names a private file, in any form the dev server would
43
+ * resolve: a plain root path, a `base`-prefixed one (this runs before Vite's
44
+ * `baseMiddleware`, so the base is still attached), `/@fs/<abs>`, with a query
45
+ * (`?raw`, `?import`, `?t=…`) or a fragment, and percent-encoded.
46
+ *
47
+ * Segment equality rather than path resolution: it needs no filesystem access
48
+ * and no knowledge of the project root or the configured base, and one rule
49
+ * covers every form above. Three details are load-bearing:
50
+ *
51
+ * - Split on backslash too, or a Windows `/@fs/C:\proj\…` path is one segment.
52
+ * - Test the raw *and* decoded spelling. Decode once, matching what Vite does:
53
+ * decoding further would only over-refuse, since `%252e` reaches the
54
+ * filesystem as a literal `%2e` filename. A URL that cannot be decoded is
55
+ * still tested raw — Vite bails on those too, so it is not a bypass.
56
+ * - Compare case-insensitively, as Vite's own deny globs do: macOS and Windows
57
+ * will happily serve `/.ASTRO-DEV-EDIT.JSON`.
58
+ */
59
+ export function isPrivateFileRequest(url: string): boolean {
60
+ const path = url.split('#')[0].split('?')[0];
61
+
62
+ let decoded = path;
63
+ try {
64
+ decoded = decodeURIComponent(path);
65
+ } catch {
66
+ // Malformed escape — test the raw spelling only.
67
+ }
68
+
69
+ for (const candidate of decoded === path ? [path] : [path, decoded]) {
70
+ for (const raw of candidate.split(/[\\/]/)) {
71
+ const segment = raw.toLowerCase();
72
+ if (PRIVATE_FILES.includes(segment) || TMP_SEGMENT_RE.test(segment)) return true;
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * Refuse with 403 rather than 404: the filename is documented publicly, so
80
+ * there is no existence to conceal, and a legible refusal is worth more to a
81
+ * developer whose `fetch` just failed. The body never echoes the path.
82
+ */
83
+ function refuse(res: Parameters<Connect.NextHandleFunction>[1]): void {
84
+ res.statusCode = 403;
85
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
86
+ res.setHeader('Cache-Control', 'no-store');
87
+ res.setHeader('X-Content-Type-Options', 'nosniff');
88
+ res.end('astro-dev-edit: this file is not served by the dev server.\n');
89
+ }
90
+
91
+ /**
92
+ * The guard plugin. Registered unconditionally from `astro:config:setup` — a
93
+ * project that disabled the editor still has the file on disk, and a protection
94
+ * that only appeared on some Astro versions would be a hole.
95
+ */
96
+ export function createPrivateFilesPlugin(): VitePlugin {
97
+ return {
98
+ name: 'astro-dev-edit:private-files',
99
+ // Never part of a build; `enforce: 'pre'` sorts this configureServer ahead
100
+ // of other plugins', so none of them can install a file server in front.
101
+ apply: 'serve',
102
+ enforce: 'pre',
103
+ configureServer(server) {
104
+ // Registered in the hook body, NOT in a returned function: the body runs
105
+ // before Vite installs its static and @fs middlewares, a returned
106
+ // function runs after them. That ordering is the whole fix.
107
+ server.middlewares.use((req, res, next) => {
108
+ if (isPrivateFileRequest(req.url ?? '')) {
109
+ refuse(res);
110
+ return;
111
+ }
112
+ next();
113
+ });
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,200 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { relative, resolve, sep } from 'node:path';
3
+ import type { PageSourceRefusal } from '../shared/protocol.ts';
4
+ import { insideRoot, isPackageOwned } from './paths.ts';
5
+
6
+ /**
7
+ * "Which file is this URL's page written in?" — answered from Astro's own route
8
+ * manifest, captured by the `astro:routes:resolved` hook in `src/index.ts`.
9
+ *
10
+ * The DOM cannot answer this. Component tags are never annotated
11
+ * (`annotate.ts`), so counting `data-astro-source-file` values makes a
12
+ * markup-dense `Nav.astro` outrank a page that merely composes components —
13
+ * which is exactly how *Open page source* used to pick the wrong file. Astro
14
+ * already knows the answer, so we ask it.
15
+ *
16
+ * Impure only in `existsSync` (injected, so tests stay pure), and injected into
17
+ * the middleware the way `content-config.ts` is: **every failure path is a
18
+ * refusal, never a throw and never a guess.** A caller handed `ok: false` tells
19
+ * the user why nothing opened.
20
+ *
21
+ * Three things about the matching are load-bearing, all verified against
22
+ * Astro's own `dist/core/routing/`:
23
+ *
24
+ * 1. **`patternRegex` matches the base-*stripped* pathname.** `getPattern`
25
+ * takes `base` but only consumes it on a branch its own guard makes
26
+ * unreachable; Astro strips the base before matching. So we strip it here.
27
+ * `config.base` is not normalized by Astro's schema, so `docs`, `/docs`
28
+ * and `/docs/` all have to work.
29
+ * 2. **`trailingSlash` is already baked into the regex** (`\/$`, `$`, `\/?$`)
30
+ * and Astro redirects to the canonical form, so we read no config for it —
31
+ * we try the pathname as given, then the slash-flipped form.
32
+ * 3. **Astro sorts routes by priority before the hook fires,** and its own
33
+ * `matchRoute` takes the first pattern hit. We iterate in array order and
34
+ * the first `page` hit wins, so an index route beats the catch-all that
35
+ * also matches it.
36
+ */
37
+
38
+ /**
39
+ * The four fields of Astro's `IntegrationResolvedRoute` this module reads,
40
+ * declared structurally rather than imported.
41
+ *
42
+ * Deliberate: `astro` is a peer over `>=5.0.0 <8`, and importing the real type
43
+ * would turn any field rename in any of those majors into a `typecheck` failure
44
+ * for a module that reads four fields. `type` is widened to `string` so Astro's
45
+ * own `RouteType` union can grow without breaking the hook's assignability.
46
+ */
47
+ export interface ResolvedRouteLike {
48
+ /** The route pattern, e.g. "/articles/[...slug]". Reported, never parsed. */
49
+ pattern: string;
50
+ /** The regex Astro itself matches a base-stripped pathname with. */
51
+ patternRegex: RegExp;
52
+ /** Root-relative, forward-slashed component path, e.g. "src/pages/index.astro". */
53
+ entrypoint: string;
54
+ /** "page" | "endpoint" | "redirect" | "fallback", widened. */
55
+ type: string;
56
+ }
57
+
58
+ export type RouteLookup =
59
+ | { ok: true; file: string; pattern: string }
60
+ | { ok: false; refusal: PageSourceRefusal };
61
+
62
+ /** A page route that renders one thing per URL. */
63
+ export interface DynamicPage {
64
+ /** The route pattern, e.g. "/articles/[...slug]". */
65
+ pattern: string;
66
+ /** Root-relative, forward-slashed entrypoint. */
67
+ file: string;
68
+ }
69
+
70
+ export interface RouteManifest {
71
+ /** The file the given browser pathname's page is written in, or a refusal. */
72
+ forPathname(pathname: string): RouteLookup;
73
+ /**
74
+ * Every **dynamic** page route whose entrypoint is a file in this project.
75
+ *
76
+ * "Dynamic" is read off the pattern rather than the regex: a bracketed
77
+ * segment is how Astro spells "this route renders one of a set", and it is
78
+ * the only routes worth asking which collection they render. Static pages are
79
+ * left out because a listing route has no single backing entry, and openable
80
+ * is filtered the same way {@link forPathname} filters it — a package-owned or
81
+ * absent entrypoint is nothing a caller can read.
82
+ */
83
+ dynamicPages(): DynamicPage[];
84
+ }
85
+
86
+ export interface RouteManifestConfig {
87
+ /** Project root (fsPath). */
88
+ root: string;
89
+ /** `config.base`, verbatim — unnormalized, as Astro's schema leaves it. */
90
+ base: string;
91
+ /**
92
+ * The routes, as a **thunk**. Astro re-fires `astro:routes:resolved` on every
93
+ * add/unlink/change under `srcDir`, so a captured array would go stale the
94
+ * first time a page is added — the same reason the schema provider takes its
95
+ * options as a thunk rather than a value.
96
+ */
97
+ routes: () => readonly ResolvedRouteLike[];
98
+ /** Seam for tests; defaults to `existsSync`. */
99
+ exists?: (abs: string) => boolean;
100
+ }
101
+
102
+ export function createRouteManifest(cfg: RouteManifestConfig): RouteManifest {
103
+ const exists = cfg.exists ?? existsSync;
104
+
105
+ // "/" → "", and `docs` / `/docs` / `/docs/` all → "/docs".
106
+ const trimmed = cfg.base.replace(/\/+$/, '');
107
+ const basePrefix =
108
+ trimmed === '' ? '' : trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
109
+
110
+ /** Browser pathname → the form Astro's own pattern regexes expect. */
111
+ function normalize(pathname: string): string | null {
112
+ let p = pathname;
113
+ // Defence only: location.pathname carries neither.
114
+ const cut = p.search(/[?#]/);
115
+ if (cut !== -1) p = p.slice(0, cut);
116
+ // Astro's dev handler decodes, and getPattern normalizes its literal
117
+ // segments — without both, a non-ASCII route never matches.
118
+ try {
119
+ p = decodeURI(p);
120
+ } catch {
121
+ /* keep the raw form; a malformed escape simply won't match */
122
+ }
123
+ p = p.normalize().replace(/\/{2,}/g, '/');
124
+ if (basePrefix) {
125
+ if (p === basePrefix) p = '/';
126
+ else if (p.startsWith(`${basePrefix}/`)) p = p.slice(basePrefix.length);
127
+ // Outside the configured base — not a route of this site at all.
128
+ else return null;
129
+ }
130
+ return p.startsWith('/') ? p : `/${p}`;
131
+ }
132
+
133
+ /** Root-relative, forward-slashed, or null when it is not a file we may read. */
134
+ function openable(entrypoint: string): string | null {
135
+ const abs = resolve(cfg.root, entrypoint.split('/').join(sep));
136
+ if (!insideRoot(cfg.root, abs) || isPackageOwned(abs)) return null;
137
+ if (!exists(abs)) return null;
138
+ return relative(cfg.root, abs).split(sep).join('/');
139
+ }
140
+
141
+ return {
142
+ dynamicPages() {
143
+ const out: DynamicPage[] = [];
144
+ const seen = new Set<string>();
145
+ for (const route of cfg.routes()) {
146
+ if (route.type !== 'page') continue;
147
+ if (!route.pattern.includes('[')) continue;
148
+ const file = openable(route.entrypoint);
149
+ if (file === null || seen.has(route.pattern)) continue;
150
+ seen.add(route.pattern);
151
+ out.push({ pattern: route.pattern, file });
152
+ }
153
+ return out;
154
+ },
155
+
156
+ forPathname(pathname) {
157
+ const routes = cfg.routes();
158
+ if (routes.length === 0) return { ok: false, refusal: 'no-routes' };
159
+ if (typeof pathname !== 'string' || pathname === '') {
160
+ return { ok: false, refusal: 'no-match' };
161
+ }
162
+ const norm = normalize(pathname);
163
+ if (norm === null) return { ok: false, refusal: 'no-match' };
164
+
165
+ // Variants outer, routes inner: an exact hit on the canonical pathname
166
+ // must beat a slash-flipped hit on a lower-priority route.
167
+ const variants =
168
+ norm === '/' ? [norm] : [norm, norm.endsWith('/') ? norm.slice(0, -1) : `${norm}/`];
169
+
170
+ for (const variant of variants) {
171
+ for (const route of routes) {
172
+ // Only pages have a template you would open. Endpoints, redirects and
173
+ // fallbacks are skipped rather than refused — a later route may match.
174
+ if (route.type !== 'page') continue;
175
+ // We don't own these regexes, and `.test` on a g/y one is stateful.
176
+ if (route.patternRegex.global || route.patternRegex.sticky) {
177
+ route.patternRegex.lastIndex = 0;
178
+ }
179
+ if (!route.patternRegex.test(variant)) continue;
180
+
181
+ // First hit wins, mirroring Astro's own matchRoute — so when it is not
182
+ // openable we say which way it failed rather than scanning on and
183
+ // opening some other route's file.
184
+ const abs = resolve(cfg.root, route.entrypoint.split('/').join(sep));
185
+ if (!insideRoot(cfg.root, abs) || isPackageOwned(abs)) {
186
+ return { ok: false, refusal: 'not-in-project' };
187
+ }
188
+ // Astro injects a default 404 page that has no file on disk.
189
+ if (!exists(abs)) return { ok: false, refusal: 'missing' };
190
+ return {
191
+ ok: true,
192
+ file: relative(cfg.root, abs).split(sep).join('/'),
193
+ pattern: route.pattern,
194
+ };
195
+ }
196
+ }
197
+ return { ok: false, refusal: 'no-match' };
198
+ },
199
+ };
200
+ }