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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- package/src/shared/unsplash.ts +51 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { astroPatcher } from './astro.ts';
|
|
2
|
+
import type { Patcher } from './types.ts';
|
|
3
|
+
|
|
4
|
+
/** Every available patcher. A future markdown/MDX patcher is added here. */
|
|
5
|
+
const patchers: readonly Patcher[] = [astroPatcher];
|
|
6
|
+
|
|
7
|
+
/** The patcher registered for a file extension (lowercased, with the dot),
|
|
8
|
+
* or undefined when in-place editing of that type isn't supported. */
|
|
9
|
+
export function patcherFor(ext: string): Patcher | undefined {
|
|
10
|
+
return patchers.find((p) => p.extensions.includes(ext));
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ClassifyResult, RefusalCode, TargetType } from '../shared/protocol.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The interface every source patcher implements. Patchers are pure
|
|
5
|
+
* string-in/string-out — no fs access; the middleware reads and writes files.
|
|
6
|
+
* Adding support for a new file type = one implementation + one entry in
|
|
7
|
+
* registry.ts.
|
|
8
|
+
*/
|
|
9
|
+
export interface Patcher {
|
|
10
|
+
/** Lowercased extensions this patcher handles, with the dot: ['.astro']. */
|
|
11
|
+
extensions: readonly string[];
|
|
12
|
+
/** Classify the element at a source location (AST truth). */
|
|
13
|
+
classify(source: string, req: { loc: string; tag: string }): Promise<ClassifyResult>;
|
|
14
|
+
/** Verify-then-patch. Never writes; returns the new source or a refusal. */
|
|
15
|
+
apply(source: string, req: PatchRequest): Promise<ApplyResult>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PatchRequest {
|
|
19
|
+
/** "line:col" from data-astro-source-loc. */
|
|
20
|
+
loc: string;
|
|
21
|
+
/** Lowercased tag name of the clicked element. */
|
|
22
|
+
tag: string;
|
|
23
|
+
targetType: TargetType;
|
|
24
|
+
/** Rendered text / attr value the client saw. */
|
|
25
|
+
original: string;
|
|
26
|
+
newText: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Server-internal result — `newSource` never crosses the wire. */
|
|
30
|
+
export type ApplyResult =
|
|
31
|
+
| { ok: true; newSource: string }
|
|
32
|
+
| { ok: false; code: RefusalCode; error: string };
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { parse } from '@astrojs/compiler';
|
|
2
|
+
import type { Plugin as VitePlugin } from 'vite';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Self-annotation for Astro ≥7 — inject `data-astro-source-file` / `-loc`
|
|
6
|
+
* ourselves when Astro's compiler no longer does.
|
|
7
|
+
*
|
|
8
|
+
* Astro 5/6 (WASM Go compiler) annotate every element in dev when the toolbar
|
|
9
|
+
* is on; the whole feature rides on those attributes. Astro 7's Rust compiler
|
|
10
|
+
* (@astrojs/compiler-rs) accepts the `annotateSourceFile` flag but emits
|
|
11
|
+
* nothing (withastro/compiler-rs#96). So on 7 we run a
|
|
12
|
+
* Vite `enforce: 'pre'` transform that annotates the raw `.astro` source
|
|
13
|
+
* BEFORE Astro's compiler sees it.
|
|
14
|
+
*
|
|
15
|
+
* The critical invariant: injected locs are computed from the ORIGINAL source,
|
|
16
|
+
* so they reference on-disk coordinates — the patcher resolves them against
|
|
17
|
+
* the on-disk file (`src/patcher/astro.ts`) and needs no changes. Injection
|
|
18
|
+
* adds no newlines, so line numbers stay true end to end; only columns shift
|
|
19
|
+
* in the compiled output (dev-only, cosmetic).
|
|
20
|
+
*
|
|
21
|
+
* Loc rules mirror the compiler's, as documented in `src/patcher/astro.ts`
|
|
22
|
+
* and pinned by `tests/helpers.ts::locOf`:
|
|
23
|
+
* - text first child → its own start;
|
|
24
|
+
* - element/expression first child → its start + 1 column (the tag name /
|
|
25
|
+
* the `{` — the compiler reports an expression's start one char before it);
|
|
26
|
+
* - childless element → the element's own start + 1 column (its tag name).
|
|
27
|
+
* Empirically verified: this walker reproduces Astro 6.4.8's own annotations
|
|
28
|
+
* for the whole playground exactly (205/205 served attributes).
|
|
29
|
+
*
|
|
30
|
+
* Pure string-in/string-out like the patchers — no fs here.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
interface Pos {
|
|
34
|
+
line: number;
|
|
35
|
+
column: number;
|
|
36
|
+
}
|
|
37
|
+
interface AstNode {
|
|
38
|
+
type: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
position?: { start: Pos; end?: Pos };
|
|
41
|
+
children?: AstNode[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// (line, column) → JS string index; same math as src/patcher/astro.ts, which
|
|
45
|
+
// verified the compiler's columns are UTF-16 units, i.e. JS string indexing.
|
|
46
|
+
function lineStartIndices(source: string): number[] {
|
|
47
|
+
const starts = [0];
|
|
48
|
+
for (let i = 0; i < source.length; i++) {
|
|
49
|
+
if (source[i] === '\n') starts.push(i + 1);
|
|
50
|
+
}
|
|
51
|
+
return starts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function indexOfPos(starts: number[], pos: Pos): number {
|
|
55
|
+
const lineStart = starts[pos.line - 1];
|
|
56
|
+
if (lineStart === undefined) return -1;
|
|
57
|
+
return lineStart + pos.column - 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function bump(pos: Pos): Pos {
|
|
61
|
+
return { line: pos.line, column: pos.column + 1 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The loc the compiler would have stamped for this element. */
|
|
65
|
+
function locForElement(node: AstNode): Pos | null {
|
|
66
|
+
const first = (node.children ?? []).find((k) => k.position);
|
|
67
|
+
if (!first) return node.position ? bump(node.position.start) : null;
|
|
68
|
+
if (first.type === 'text') return first.position!.start;
|
|
69
|
+
return bump(first.position!.start);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Tags that must never be stamped. Astro treats a `<script>` or `<style>`
|
|
74
|
+
* carrying an attribute it does not recognise as `is:inline` — no bundling,
|
|
75
|
+
* no TypeScript, no import resolution, no `type="module"` — which breaks
|
|
76
|
+
* every script on the page (its own `ClientRouter` included). Neither is an
|
|
77
|
+
* editable element, so nothing in the editing surface is lost.
|
|
78
|
+
*/
|
|
79
|
+
const NEVER_ANNOTATE = new Set(['script', 'style']);
|
|
80
|
+
|
|
81
|
+
/** Plain lowercase HTML elements only — components/fragments are never
|
|
82
|
+
* annotated (matches compiler behavior; the client walks up via
|
|
83
|
+
* nearestSource anyway), nor is anything in NEVER_ANNOTATE. */
|
|
84
|
+
function isAnnotatable(node: AstNode): boolean {
|
|
85
|
+
return (
|
|
86
|
+
node.type === 'element' &&
|
|
87
|
+
!!node.name &&
|
|
88
|
+
/^[a-z]/.test(node.name) &&
|
|
89
|
+
!NEVER_ANNOTATE.has(node.name)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function escapeAttr(value: string): string {
|
|
94
|
+
return value.replace(/&/g, '&').replace(/"/g, '"');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface Insertion {
|
|
98
|
+
index: number;
|
|
99
|
+
text: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Annotate every plain element in an `.astro` source with the
|
|
104
|
+
* `data-astro-source-*` attributes Astro 5/6 would have emitted.
|
|
105
|
+
* `file` is the absolute path stamped into the attribute (what the Vite
|
|
106
|
+
* transform receives as its module id).
|
|
107
|
+
*/
|
|
108
|
+
export async function annotateAstroSource(source: string, file: string): Promise<string> {
|
|
109
|
+
const { ast } = await parse(source, { position: true });
|
|
110
|
+
const starts = lineStartIndices(source);
|
|
111
|
+
const insertions: Insertion[] = [];
|
|
112
|
+
const fileAttr = escapeAttr(file);
|
|
113
|
+
|
|
114
|
+
const walk = (node: AstNode): void => {
|
|
115
|
+
if (isAnnotatable(node) && node.position) {
|
|
116
|
+
const loc = locForElement(node);
|
|
117
|
+
// Insertion point: right after `<tagname`, before any existing
|
|
118
|
+
// attributes — so expression attributes / spreads are never touched.
|
|
119
|
+
const tagStart = indexOfPos(starts, node.position.start);
|
|
120
|
+
if (loc && tagStart >= 0) {
|
|
121
|
+
insertions.push({
|
|
122
|
+
index: tagStart + 1 + node.name!.length,
|
|
123
|
+
text:
|
|
124
|
+
` data-astro-source-file="${fileAttr}"` +
|
|
125
|
+
` data-astro-source-loc="${loc.line}:${loc.column}"`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const child of node.children ?? []) walk(child);
|
|
130
|
+
};
|
|
131
|
+
walk(ast as unknown as AstNode);
|
|
132
|
+
|
|
133
|
+
if (insertions.length === 0) return source;
|
|
134
|
+
insertions.sort((a, b) => a.index - b.index);
|
|
135
|
+
// Build with a parts array + single join, not `out += …` in the loop: the
|
|
136
|
+
// latter recopies a growing string per insertion (O(k²) over the output).
|
|
137
|
+
// A `.map()` loop lives in source once, so real files have few insertions —
|
|
138
|
+
// but a large hand-written template shouldn't degrade.
|
|
139
|
+
const parts: string[] = [];
|
|
140
|
+
let cursor = 0;
|
|
141
|
+
for (const ins of insertions) {
|
|
142
|
+
parts.push(source.slice(cursor, ins.index), ins.text);
|
|
143
|
+
cursor = ins.index;
|
|
144
|
+
}
|
|
145
|
+
parts.push(source.slice(cursor));
|
|
146
|
+
return parts.join('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The dev-only Vite plugin. Ordering matters twice over: Astro's `astro:build`
|
|
151
|
+
* plugin is itself `enforce: 'pre'` and compiles main `.astro` modules in a
|
|
152
|
+
* plain `transform` handler — and integration-injected plugins land AFTER it
|
|
153
|
+
* in the resolved array, so plugin-level `enforce` alone is not enough (the
|
|
154
|
+
* transform would receive compiled JS, not source). Hook-level
|
|
155
|
+
* `order: 'pre'` is the decisive lever: Vite runs order-'pre' transform
|
|
156
|
+
* handlers before all plain handlers regardless of array position, so the
|
|
157
|
+
* compiler (WASM or Rust) receives the already-annotated source. Only the
|
|
158
|
+
* main module is transformed — style/script sub-requests carry a
|
|
159
|
+
* `?astro&type=…` query and no longer end in `.astro`.
|
|
160
|
+
*/
|
|
161
|
+
export function createAnnotatePlugin(): VitePlugin {
|
|
162
|
+
return {
|
|
163
|
+
name: 'astro-dev-edit:annotate',
|
|
164
|
+
enforce: 'pre',
|
|
165
|
+
transform: {
|
|
166
|
+
order: 'pre',
|
|
167
|
+
async handler(code, id) {
|
|
168
|
+
if (!id.endsWith('.astro')) return null;
|
|
169
|
+
return { code: await annotateAstroSource(code, id), map: null };
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { mkdir, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
3
|
+
import type { AssetInfo, UploadRequest } from '../shared/protocol.ts';
|
|
4
|
+
import { insideRoot, isServableAsset, toWebPath } from './paths.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Asset handling: read-only image listing for the swap panel, and image
|
|
8
|
+
* uploads. Uploads write NEW files into a configured asset dir — never a
|
|
9
|
+
* source-file patch. (spec §6.3, §11)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.svg']);
|
|
13
|
+
|
|
14
|
+
const EXT_BY_MIME: Record<string, string> = {
|
|
15
|
+
'image/jpeg': '.jpg',
|
|
16
|
+
'image/png': '.png',
|
|
17
|
+
'image/webp': '.webp',
|
|
18
|
+
'image/avif': '.avif',
|
|
19
|
+
'image/gif': '.gif',
|
|
20
|
+
'image/svg+xml': '.svg',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function walk(dir: string): Promise<string[]> {
|
|
24
|
+
const found: string[] = [];
|
|
25
|
+
const items = await readdir(dir, { withFileTypes: true });
|
|
26
|
+
for (const item of items) {
|
|
27
|
+
const full = join(dir, item.name);
|
|
28
|
+
if (item.isDirectory()) found.push(...(await walk(full)));
|
|
29
|
+
else if (item.isFile()) found.push(full);
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** List image files under the configured asset dirs, as the paths they are
|
|
35
|
+
* served at, with size, mtime and whether a *build* will still serve them.
|
|
36
|
+
* Read-only. Every directory is confined to the project root. Sorted by path;
|
|
37
|
+
* the client re-sorts (by recency, by default). (spec §6.3, §8)
|
|
38
|
+
*
|
|
39
|
+
* `assetDirs` deliberately spans both worlds — `src/assets` has to be listed
|
|
40
|
+
* for `image()` fields — so servability is carried per file rather than left
|
|
41
|
+
* for each picker to infer from the path. (issue #9) */
|
|
42
|
+
export async function listAssets(
|
|
43
|
+
root: string,
|
|
44
|
+
assetDirs: string[],
|
|
45
|
+
publicDir = 'public',
|
|
46
|
+
): Promise<AssetInfo[]> {
|
|
47
|
+
// A Map because asset dirs may nest (e.g. public/photos inside public), so
|
|
48
|
+
// the same file can be reached twice — keyed by web path, first one wins.
|
|
49
|
+
const out = new Map<string, AssetInfo>();
|
|
50
|
+
for (const dir of assetDirs) {
|
|
51
|
+
const abs = resolve(root, dir);
|
|
52
|
+
// Refuse anything that escaped the root (e.g. via `..`). (spec §8)
|
|
53
|
+
if (!insideRoot(root, abs)) continue;
|
|
54
|
+
let entries: string[];
|
|
55
|
+
try {
|
|
56
|
+
entries = await walk(abs);
|
|
57
|
+
} catch {
|
|
58
|
+
continue; // dir may not exist; skip quietly
|
|
59
|
+
}
|
|
60
|
+
for (const file of entries) {
|
|
61
|
+
const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
|
|
62
|
+
if (!IMAGE_EXT.has(ext)) continue;
|
|
63
|
+
const path = toWebPath(root, file, publicDir);
|
|
64
|
+
if (out.has(path)) continue;
|
|
65
|
+
// Statted after the extension filter, so non-images cost nothing. A file
|
|
66
|
+
// deleted between the readdir and the stat is simply left out.
|
|
67
|
+
try {
|
|
68
|
+
const info = await stat(file);
|
|
69
|
+
out.set(path, {
|
|
70
|
+
path,
|
|
71
|
+
size: info.size,
|
|
72
|
+
mtime: info.mtimeMs,
|
|
73
|
+
servable: isServableAsset(root, file, publicDir),
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...out.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The mime type of a data: URL without decoding its payload — for policy
|
|
84
|
+
* checks that must not pay for a base64 decode of the whole image. Empty
|
|
85
|
+
* string when the value isn't a data URL. */
|
|
86
|
+
export function dataUrlMime(dataUrl: string): string {
|
|
87
|
+
return /^data:([^;,]+)/.exec(dataUrl ?? '')?.[1]?.toLowerCase() ?? '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Split a data: URL into its mime type and decoded bytes. Throws on
|
|
91
|
+
* anything that isn't a data URL. */
|
|
92
|
+
export function parseDataUrl(dataUrl: string): { mime: string; data: Buffer } {
|
|
93
|
+
const m = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(dataUrl ?? '');
|
|
94
|
+
if (!m) throw new Error('expected a data: URL');
|
|
95
|
+
const mime = m[1].toLowerCase();
|
|
96
|
+
const data = m[2] ? Buffer.from(m[3], 'base64') : Buffer.from(decodeURIComponent(m[3]));
|
|
97
|
+
return { mime, data };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Sanitise a user-supplied filename to a safe basename with an allowed ext. */
|
|
101
|
+
export function safeFileName(name: string, fallbackExt: string): string {
|
|
102
|
+
const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '-').replace(/^-+/, '');
|
|
103
|
+
let ext = extname(base).toLowerCase();
|
|
104
|
+
let stem = base.slice(0, base.length - ext.length) || 'image';
|
|
105
|
+
if (!IMAGE_EXT.has(ext)) ext = fallbackExt;
|
|
106
|
+
return `${stem}${ext}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Write image bytes into the configured upload directory. The destination is
|
|
111
|
+
* confined to the upload dir inside the project root, the mime must be an
|
|
112
|
+
* allowed image type, and the name is sanitised. On a name clash a numeric
|
|
113
|
+
* suffix is added rather than overwriting. Returns the web-servable path and
|
|
114
|
+
* the name actually written. (safe: writes a NEW asset file, never patches
|
|
115
|
+
* source)
|
|
116
|
+
*
|
|
117
|
+
* Bytes-in rather than data-URL-in because not every caller has a data URL:
|
|
118
|
+
* `/unsplash/import` downloads a response body, and synthesising a data URL
|
|
119
|
+
* from it would mean a ~33% larger base64 string and a decode straight back to
|
|
120
|
+
* the Buffer we started with.
|
|
121
|
+
*/
|
|
122
|
+
export async function saveBuffer(
|
|
123
|
+
root: string,
|
|
124
|
+
uploadDir: string,
|
|
125
|
+
file: { mime: string; data: Buffer; filename: string },
|
|
126
|
+
publicDir = 'public',
|
|
127
|
+
): Promise<{ webPath: string; filename: string }> {
|
|
128
|
+
const fallbackExt = EXT_BY_MIME[file.mime];
|
|
129
|
+
if (!fallbackExt) throw new Error(`unsupported image type: ${file.mime}`);
|
|
130
|
+
|
|
131
|
+
// Uploads land in the configured upload dir, confined to the project root.
|
|
132
|
+
const dir = resolve(root, uploadDir);
|
|
133
|
+
if (!insideRoot(root, dir)) throw new Error('upload directory escapes the project root');
|
|
134
|
+
|
|
135
|
+
const fileName = safeFileName(file.filename || 'upload', fallbackExt);
|
|
136
|
+
let target = join(dir, fileName);
|
|
137
|
+
// Confirm the resolved target is still inside the asset dir. (spec §8)
|
|
138
|
+
if (!insideRoot(dir, target)) throw new Error('path escapes asset dir');
|
|
139
|
+
|
|
140
|
+
// Avoid clobbering an existing file.
|
|
141
|
+
const stem = fileName.slice(0, fileName.length - extname(fileName).length);
|
|
142
|
+
const ext = extname(fileName);
|
|
143
|
+
let n = 1;
|
|
144
|
+
const existing = new Set(await walk(dir).catch(() => []));
|
|
145
|
+
while (existing.has(target)) {
|
|
146
|
+
target = join(dir, `${stem}-${n++}${ext}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await mkdir(dirname(target), { recursive: true });
|
|
150
|
+
await writeFile(target, file.data);
|
|
151
|
+
|
|
152
|
+
return { webPath: toWebPath(root, target, publicDir), filename: basename(target) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Write an uploaded image into the configured upload directory. Accepts a
|
|
157
|
+
* data-URL; everything past the decode is {@link saveBuffer}.
|
|
158
|
+
*/
|
|
159
|
+
export async function saveUpload(
|
|
160
|
+
root: string,
|
|
161
|
+
uploadDir: string,
|
|
162
|
+
payload: UploadRequest,
|
|
163
|
+
publicDir = 'public',
|
|
164
|
+
): Promise<{ webPath: string }> {
|
|
165
|
+
const { mime, data } = parseDataUrl(payload.dataUrl);
|
|
166
|
+
return saveBuffer(root, uploadDir, { mime, data, filename: payload.filename }, publicDir);
|
|
167
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { insideRoot } from './paths.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Finding a collection's entry files on disk — the one piece of knowledge the
|
|
7
|
+
* Items listing and the entry resolver both need.
|
|
8
|
+
*
|
|
9
|
+
* It lived inside the `/collection/entries` handler until `/entry/resolve`
|
|
10
|
+
* needed the same walk. Two copies of "which files are this collection's
|
|
11
|
+
* entries" is exactly the drift that makes a resolver point at a file the
|
|
12
|
+
* listing never shows, so it is one module with one answer.
|
|
13
|
+
*
|
|
14
|
+
* Impure only in `readdir` (injected, so tests stay pure) and confined by
|
|
15
|
+
* {@link inContentRoots} before it is ever called.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Cap on one listing. Each entry can cost a stat and a frontmatter parse
|
|
19
|
+
* downstream, so a pathological directory can't turn a panel open into a long
|
|
20
|
+
* scan. Exceeding it is reported, never hidden. */
|
|
21
|
+
export const MAX_ENTRIES_LISTED = 500;
|
|
22
|
+
|
|
23
|
+
/** Injected directory walk; defaults to a recursive `readdir`. */
|
|
24
|
+
export type ReadDirRecursive = (dirAbs: string) => Promise<string[]>;
|
|
25
|
+
|
|
26
|
+
const defaultReadDir: ReadDirRecursive = async (dirAbs) =>
|
|
27
|
+
(await readdir(dirAbs, { recursive: true })).map(String);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a directory is inside the configured content roots.
|
|
31
|
+
*
|
|
32
|
+
* `insideRoot` alone is not enough: it answers "under the project", while
|
|
33
|
+
* `contentRoots` is the narrower gate the user controls. Both are asked, in
|
|
34
|
+
* that order, exactly as `/collection/entries` has always asked them.
|
|
35
|
+
*/
|
|
36
|
+
export function inContentRoots(root: string, dirAbs: string, contentRoots: string[]): boolean {
|
|
37
|
+
if (!insideRoot(root, dirAbs)) return false;
|
|
38
|
+
const rel = relative(root, dirAbs);
|
|
39
|
+
return contentRoots.some(
|
|
40
|
+
(cr) => rel === cr || rel.startsWith(cr.endsWith(sep) ? cr : cr + sep),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EntryListing {
|
|
45
|
+
/** Directory-relative, forward-slashed file names, sorted. */
|
|
46
|
+
names: string[];
|
|
47
|
+
/** True when the directory held more than {@link MAX_ENTRIES_LISTED}. */
|
|
48
|
+
truncated: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A collection directory's entry files, capped and sorted.
|
|
53
|
+
*
|
|
54
|
+
* A missing or unreadable directory is an empty listing, not a throw: a
|
|
55
|
+
* collection declared in the config without its directory yet is a real and
|
|
56
|
+
* common state, and neither caller has anything better to say about it.
|
|
57
|
+
*/
|
|
58
|
+
export async function listEntryFiles(
|
|
59
|
+
dirAbs: string,
|
|
60
|
+
extensions: readonly string[],
|
|
61
|
+
readDir: ReadDirRecursive = defaultReadDir,
|
|
62
|
+
): Promise<EntryListing> {
|
|
63
|
+
let all: string[];
|
|
64
|
+
try {
|
|
65
|
+
all = await readDir(dirAbs);
|
|
66
|
+
} catch {
|
|
67
|
+
return { names: [], truncated: false };
|
|
68
|
+
}
|
|
69
|
+
const names = all
|
|
70
|
+
.map((f) => f.split(sep).join('/'))
|
|
71
|
+
.filter((f) => extensions.some((e) => f.toLowerCase().endsWith(e)))
|
|
72
|
+
.sort();
|
|
73
|
+
return { names: names.slice(0, MAX_ENTRIES_LISTED), truncated: names.length > MAX_ENTRIES_LISTED };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A directory-relative entry file name as its **entry id**.
|
|
78
|
+
*
|
|
79
|
+
* For a glob loader based at the collection directory this is precisely Astro's
|
|
80
|
+
* own id — the posix path minus the extension, nested directories included — so
|
|
81
|
+
* `2026/hello.md` is `2026/hello`, and a detail URL's tail can be matched
|
|
82
|
+
* against it directly. That equivalence is what `/entry/resolve` rests on.
|
|
83
|
+
*/
|
|
84
|
+
export function entryId(name: string): string {
|
|
85
|
+
return name.replace(/\.[^./]+$/, '');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Absolute path of a collection's directory, repo-relative `dir` in hand. */
|
|
89
|
+
export function collectionDirAbs(root: string, dir: string): string {
|
|
90
|
+
return resolve(root, dir);
|
|
91
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { ViteDevServer } from 'vite';
|
|
4
|
+
import type { FieldOverride } from '../shared/protocol.ts';
|
|
5
|
+
import { IMAGE_STUB_DESCRIPTION } from './schema-introspect.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves "which collection backs this file, and what is its zod schema?" by
|
|
9
|
+
* loading the project's own content config through the dev server's module
|
|
10
|
+
* pipeline. Impure by design and injected into the middleware, so tests can
|
|
11
|
+
* stub it. Every failure path returns null — the entry panel then falls back
|
|
12
|
+
* to value-based field inference instead of erroring.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The editor half of a field — widget, label, hidden. Defined in `protocol.ts`
|
|
16
|
+
* because the collection designer now reads and writes it over the wire; this
|
|
17
|
+
* alias keeps the option-facing name the config documents. */
|
|
18
|
+
export type EntryFieldOverride = FieldOverride;
|
|
19
|
+
|
|
20
|
+
export interface EntryEditorOptions {
|
|
21
|
+
/** Repo-relative path to the content config; auto-detected when omitted. */
|
|
22
|
+
configPath?: string;
|
|
23
|
+
collections?: Record<
|
|
24
|
+
string,
|
|
25
|
+
{
|
|
26
|
+
/** Repo-relative collection dir; defaults to src/content/<name>. */
|
|
27
|
+
dir?: string;
|
|
28
|
+
/** Extension for entries created via the panel. When omitted it is
|
|
29
|
+
* inferred from the collection's existing entries (falling back to .md). */
|
|
30
|
+
extension?: '.md' | '.mdx';
|
|
31
|
+
/**
|
|
32
|
+
* Offer the entry drawer on this collection's detail pages. Off by
|
|
33
|
+
* default: switching it on — from the Collections panel, or here — is
|
|
34
|
+
* what replaces hand-emitting the `astro-dev-edit:page-source` meta tag.
|
|
35
|
+
* Setting it here locks the panel's switch, like any other config value.
|
|
36
|
+
*/
|
|
37
|
+
pageEditing?: boolean;
|
|
38
|
+
fields?: Record<string, EntryFieldOverride>;
|
|
39
|
+
}
|
|
40
|
+
>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EntryCollectionInfo {
|
|
44
|
+
collection: string;
|
|
45
|
+
/** Repo-relative directory holding the collection's entries. */
|
|
46
|
+
dir: string;
|
|
47
|
+
/** The collection's zod object schema, or null when not resolvable. */
|
|
48
|
+
schema: unknown;
|
|
49
|
+
/** Configured extension for new entries; when absent the create route
|
|
50
|
+
* infers one from the collection's existing entries. */
|
|
51
|
+
extension?: '.md' | '.mdx';
|
|
52
|
+
/** Whether this collection's detail pages offer the entry drawer — the
|
|
53
|
+
* effective value, config over stored. Absent reads as off, so a caller that
|
|
54
|
+
* knows nothing about the switch (a test stub) leaves it off. */
|
|
55
|
+
pageEditing?: boolean;
|
|
56
|
+
fieldConfig: Record<string, EntryFieldOverride>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface EntrySchemaProvider {
|
|
60
|
+
/** Info for a repo-relative entry file path, or null when unmapped. */
|
|
61
|
+
forFile(relFile: string): Promise<EntryCollectionInfo | null>;
|
|
62
|
+
/** Info for a collection by name (create flow), or null when unknown. */
|
|
63
|
+
forCollection(name: string): Promise<EntryCollectionInfo | null>;
|
|
64
|
+
/**
|
|
65
|
+
* Every collection the project declares, in config order, plus any configured
|
|
66
|
+
* explicitly in `entryEditor.collections`. Empty when no content config
|
|
67
|
+
* resolved — the collections panel then says so rather than showing nothing.
|
|
68
|
+
*/
|
|
69
|
+
listCollections(): Promise<EntryCollectionInfo[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Repo-relative path of the content config this provider reads, or null when
|
|
72
|
+
* none exists. The collection designer patches **this** path and never one the
|
|
73
|
+
* client supplies, which is what keeps a schema write confined.
|
|
74
|
+
*/
|
|
75
|
+
configPath(): Promise<string | null>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const CONFIG_CANDIDATES = [
|
|
79
|
+
'src/content.config.ts',
|
|
80
|
+
'src/content.config.mts',
|
|
81
|
+
'src/content.config.js',
|
|
82
|
+
'src/content.config.mjs',
|
|
83
|
+
'src/content/config.ts',
|
|
84
|
+
'src/content/config.mts',
|
|
85
|
+
'src/content/config.js',
|
|
86
|
+
'src/content/config.mjs',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
export function createSchemaProvider(
|
|
90
|
+
server: ViteDevServer,
|
|
91
|
+
root: string,
|
|
92
|
+
/**
|
|
93
|
+
* The entry-editor options, **as a thunk**. Collection dirs and per-field
|
|
94
|
+
* widget overrides can now come from the Settings panel as well as from
|
|
95
|
+
* `astro.config.mjs`, so reading them once at construction would freeze the
|
|
96
|
+
* panel's changes out until the next dev-server restart.
|
|
97
|
+
*/
|
|
98
|
+
readOptions: () => Promise<EntryEditorOptions>,
|
|
99
|
+
): EntrySchemaProvider {
|
|
100
|
+
function collectionDir(explicit: Explicit, name: string): string {
|
|
101
|
+
return normalizeDir(explicit[name]?.dir ?? `src/content/${name}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The content config that exists on disk, repo-relative; null when none does.
|
|
105
|
+
* Server-side discovery only — a configured path or the conventional
|
|
106
|
+
* candidates, never anything a request names. */
|
|
107
|
+
function findConfig(configPath: string | undefined): string | null {
|
|
108
|
+
const candidates = configPath ? [configPath] : CONFIG_CANDIDATES;
|
|
109
|
+
return candidates.find((c) => existsSync(join(root, c))) ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Load `collections` from the project's content config; null on any failure.
|
|
113
|
+
* ssrLoadModule is cached by Vite and invalidated when the config changes,
|
|
114
|
+
* so calling per-request stays cheap and always fresh. */
|
|
115
|
+
async function loadCollections(
|
|
116
|
+
configPath: string | undefined,
|
|
117
|
+
): Promise<Record<string, { schema?: unknown }> | null> {
|
|
118
|
+
try {
|
|
119
|
+
const rel = findConfig(configPath);
|
|
120
|
+
if (!rel) return null;
|
|
121
|
+
const mod = (await server.ssrLoadModule('/' + rel.replace(/\\/g, '/'))) as {
|
|
122
|
+
collections?: Record<string, { schema?: unknown }>;
|
|
123
|
+
};
|
|
124
|
+
const collections = mod.collections;
|
|
125
|
+
return collections && typeof collections === 'object' ? collections : null;
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A schema may be a function of a context ({ image }) — call it with a stub
|
|
132
|
+
* whose image() is a plain string schema from the project's own zod, tagged
|
|
133
|
+
* so the introspector renders it as an image field. */
|
|
134
|
+
async function resolveSchema(schema: unknown): Promise<unknown> {
|
|
135
|
+
if (typeof schema !== 'function') return schema ?? null;
|
|
136
|
+
try {
|
|
137
|
+
const zmod = (await server.ssrLoadModule('astro/zod')) as {
|
|
138
|
+
z?: { string(): { describe(d: string): unknown } };
|
|
139
|
+
};
|
|
140
|
+
const z = zmod.z;
|
|
141
|
+
if (!z) return null;
|
|
142
|
+
const image = () => z.string().describe(IMAGE_STUB_DESCRIPTION);
|
|
143
|
+
return (schema as (ctx: { image: typeof image }) => unknown)({ image });
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function info(name: string): Promise<EntryCollectionInfo | null> {
|
|
150
|
+
const options = await readOptions();
|
|
151
|
+
const explicit = options.collections ?? {};
|
|
152
|
+
const collections = await loadCollections(options.configPath);
|
|
153
|
+
const entry = collections?.[name];
|
|
154
|
+
// A collection configured explicitly is usable even without a config module
|
|
155
|
+
// (dir + overrides still apply; fields fall back to inference).
|
|
156
|
+
if (!entry && !explicit[name]) return null;
|
|
157
|
+
return {
|
|
158
|
+
collection: name,
|
|
159
|
+
dir: collectionDir(explicit, name),
|
|
160
|
+
schema: entry ? await resolveSchema(entry.schema) : null,
|
|
161
|
+
extension: explicit[name]?.extension,
|
|
162
|
+
pageEditing: explicit[name]?.pageEditing === true,
|
|
163
|
+
fieldConfig: explicit[name]?.fields ?? {},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
async forCollection(name) {
|
|
169
|
+
return info(name);
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async listCollections() {
|
|
173
|
+
const options = await readOptions();
|
|
174
|
+
const declared = await loadCollections(options.configPath);
|
|
175
|
+
const names = new Set([
|
|
176
|
+
...Object.keys(declared ?? {}),
|
|
177
|
+
...Object.keys(options.collections ?? {}),
|
|
178
|
+
]);
|
|
179
|
+
const out: EntryCollectionInfo[] = [];
|
|
180
|
+
for (const name of names) {
|
|
181
|
+
const one = await info(name);
|
|
182
|
+
if (one) out.push(one);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async configPath() {
|
|
188
|
+
return findConfig((await readOptions()).configPath);
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
async forFile(relFile) {
|
|
192
|
+
const options = await readOptions();
|
|
193
|
+
const explicit = options.collections ?? {};
|
|
194
|
+
const posix = relFile.replace(/\\/g, '/');
|
|
195
|
+
// Explicit dirs win, then the src/content/<name>/ convention.
|
|
196
|
+
for (const name of Object.keys(explicit)) {
|
|
197
|
+
if (posix.startsWith(collectionDir(explicit, name) + '/')) return info(name);
|
|
198
|
+
}
|
|
199
|
+
const m = posix.match(/^src\/content\/([^/]+)\//);
|
|
200
|
+
return m ? info(m[1]) : null;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The `collections` map from {@link EntryEditorOptions}, non-optional. */
|
|
206
|
+
type Explicit = NonNullable<EntryEditorOptions['collections']>;
|
|
207
|
+
|
|
208
|
+
function normalizeDir(dir: string): string {
|
|
209
|
+
return dir.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
210
|
+
}
|