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,94 @@
1
+ import type { AstroIntegrationLogger } from 'astro';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import type { Connect } from 'vite';
4
+
5
+ /**
6
+ * Minimal route table + dispatcher for the /__dev-edit endpoints. Routes are
7
+ * matched on exact method + pathname (query stripped) — adding an endpoint is
8
+ * one entry in the middleware's table, with body reading, JSON parsing, and
9
+ * error mapping handled once, here.
10
+ */
11
+
12
+ export const BASE = '/__dev-edit';
13
+
14
+ export interface RouteResult {
15
+ status: number;
16
+ body: unknown;
17
+ }
18
+
19
+ export interface Route {
20
+ method: 'GET' | 'POST';
21
+ /** Exact pathname under BASE, e.g. '/apply'. */
22
+ path: string;
23
+ /** Body size cap; required for POST routes. The body is JSON-parsed. */
24
+ maxBytes?: number;
25
+ /** Warn-log prefix on failure: "<label> failed: <err>". */
26
+ label: string;
27
+ /** Response fallback when a non-Error is thrown; defaults to "<label> failed". */
28
+ fallback?: string;
29
+ /** Handle the request. `body` is the parsed JSON for POST, undefined for GET. */
30
+ handler(body: unknown, req: Connect.IncomingMessage): Promise<RouteResult>;
31
+ /** Override the default 400-with-message error response. */
32
+ onError?(err: unknown): RouteResult;
33
+ }
34
+
35
+ /** Read a request body up to a size cap. */
36
+ export function readBody(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
37
+ return new Promise((res, rej) => {
38
+ const chunks: Buffer[] = [];
39
+ let size = 0;
40
+ req.on('data', (c: Buffer) => {
41
+ size += c.length;
42
+ if (size > maxBytes) {
43
+ rej(new Error('payload too large'));
44
+ req.destroy();
45
+ return;
46
+ }
47
+ chunks.push(c);
48
+ });
49
+ req.on('end', () => res(Buffer.concat(chunks)));
50
+ req.on('error', rej);
51
+ });
52
+ }
53
+
54
+ export function json(res: ServerResponse, status: number, body: unknown): void {
55
+ const payload = JSON.stringify(body);
56
+ res.statusCode = status;
57
+ res.setHeader('Content-Type', 'application/json');
58
+ res.end(payload);
59
+ }
60
+
61
+ /** Match and run the route for a request already known to be under BASE. */
62
+ export async function dispatch(
63
+ routes: readonly Route[],
64
+ logger: AstroIntegrationLogger,
65
+ req: Connect.IncomingMessage,
66
+ res: ServerResponse,
67
+ ): Promise<void> {
68
+ const url = req.url ?? '';
69
+ const pathname = new URL(url, 'http://localhost').pathname;
70
+ const sub = pathname.slice(BASE.length);
71
+ const route = routes.find((r) => r.method === req.method && r.path === sub);
72
+ if (!route) {
73
+ logger.warn(`unhandled text-edit request: ${req.method} ${url}`);
74
+ json(res, 404, { error: 'not implemented' });
75
+ return;
76
+ }
77
+
78
+ try {
79
+ let body: unknown;
80
+ if (route.method === 'POST') {
81
+ const buf = await readBody(req, route.maxBytes ?? 64 * 1024);
82
+ body = JSON.parse(buf.toString('utf8'));
83
+ }
84
+ const result = await route.handler(body, req);
85
+ json(res, result.status, result.body);
86
+ } catch (err) {
87
+ logger.warn(`${route.label} failed: ${String(err)}`);
88
+ const mapped = route.onError?.(err) ?? {
89
+ status: 400,
90
+ body: { error: err instanceof Error ? err.message : (route.fallback ?? `${route.label} failed`) },
91
+ };
92
+ json(res, mapped.status, mapped.body);
93
+ }
94
+ }
@@ -0,0 +1,233 @@
1
+ import type { FieldDescriptor, FieldType } from '../shared/protocol.ts';
2
+ import { adapterFor, type ZodAdapter, type ZodNode } from './zod-adapt.ts';
3
+
4
+ /**
5
+ * Turn a collection's zod schema into form-field descriptors for the entry
6
+ * panel. Pure and duck-typed: every zod internal is read through
7
+ * `zod-adapt.ts`, which speaks both v3 (Astro 5/6) and v4 (Astro 7) — we never
8
+ * import zod at runtime, so there is no dual-instance hazard. Anything
9
+ * unrecognized degrades to `null` (whole schema) or `json` (single field), never
10
+ * an error; the middleware then falls back to value-based inference.
11
+ */
12
+
13
+ /** Mark used by the schema-function `image()` stub (see content-config.ts). */
14
+ export const IMAGE_STUB_DESCRIPTION = 'atx:image';
15
+
16
+ /** Every {@link FieldType} as a runtime list — `protocol.ts` stays types-only, so
17
+ * the one place that needs to *validate* a widget name gets it from here. The
18
+ * `Record` makes it exhaustive: adding a type to the union without adding it
19
+ * here fails typecheck. */
20
+ const ALL_FIELD_TYPES: Record<FieldType, true> = {
21
+ text: true,
22
+ textarea: true,
23
+ date: true,
24
+ number: true,
25
+ boolean: true,
26
+ select: true,
27
+ tags: true,
28
+ image: true,
29
+ json: true,
30
+ };
31
+ export const FIELD_TYPES = Object.keys(ALL_FIELD_TYPES) as FieldType[];
32
+
33
+ interface TerminalDescriptor {
34
+ type: FieldType;
35
+ options?: string[];
36
+ assetRef?: 'relative';
37
+ }
38
+
39
+ function terminalType(a: ZodAdapter, inner: ZodNode): TerminalDescriptor {
40
+ switch (a.kind(inner)) {
41
+ case 'string': {
42
+ // Astro's `image()` is stubbed as a described string (content-config.ts).
43
+ // Its values are paths relative to the *entry file*, not web URLs, so the
44
+ // client needs that flagged to preview and write the right shape.
45
+ return a.description(inner) === IMAGE_STUB_DESCRIPTION
46
+ ? { type: 'image', assetRef: 'relative' }
47
+ : { type: 'text' };
48
+ }
49
+ case 'date':
50
+ return { type: 'date' };
51
+ case 'number':
52
+ return { type: 'number' };
53
+ case 'boolean':
54
+ return { type: 'boolean' };
55
+ case 'enum': {
56
+ const options = a.enumOptions(inner);
57
+ return options ? { type: 'select', options } : { type: 'json' };
58
+ }
59
+ case 'literal':
60
+ return typeof a.literalValue(inner) === 'string' ? { type: 'text' } : { type: 'json' };
61
+ case 'array': {
62
+ const element = a.arrayElement(inner);
63
+ if (!element) return { type: 'json' };
64
+ return a.kind(a.unwrap(element).inner) === 'string' ? { type: 'tags' } : { type: 'json' };
65
+ }
66
+ default:
67
+ return { type: 'json' };
68
+ }
69
+ }
70
+
71
+ function humanize(name: string): string {
72
+ return name
73
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
74
+ .replace(/[_-]+/g, ' ')
75
+ .replace(/^./, (c) => c.toUpperCase());
76
+ }
77
+
78
+ /**
79
+ * Field descriptors from a zod object schema, or null when the value isn't
80
+ * one (function schemas the provider couldn't call, non-zod, a future major, …).
81
+ */
82
+ export function zodToFields(schema: unknown): FieldDescriptor[] | null {
83
+ const a = adapterFor(schema);
84
+ if (!a) return null;
85
+ const root = schema as ZodNode;
86
+ if (a.kind(root) !== 'object') return null;
87
+ const shape = a.shape(root);
88
+ if (!shape) return null;
89
+
90
+ const fields: FieldDescriptor[] = [];
91
+ for (const [name, field] of Object.entries(shape)) {
92
+ const { inner, required, defaultValue } = a.unwrap(field);
93
+ const { type, options, assetRef } = terminalType(a, inner);
94
+ fields.push({
95
+ name,
96
+ label: humanize(name),
97
+ type,
98
+ required,
99
+ ...(options ? { options } : {}),
100
+ ...(assetRef ? { assetRef } : {}),
101
+ ...(defaultValue !== undefined ? { defaultValue } : {}),
102
+ present: false, // filled in by the endpoint against the file's data
103
+ source: 'schema',
104
+ });
105
+ }
106
+ return fields;
107
+ }
108
+
109
+ /** The object schema's shape record, or null when unavailable. */
110
+ export function shapeOf(schema: unknown): Record<string, unknown> | null {
111
+ const a = adapterFor(schema);
112
+ if (!a) return null;
113
+ const root = schema as ZodNode;
114
+ return a.kind(root) === 'object' ? a.shape(root) : null;
115
+ }
116
+
117
+ /** Our YAML parse keeps dates as strings, but a project's `z.date()` expects a
118
+ * Date (Astro's own YAML pipeline hands it one) — bridge before validating.
119
+ * `z.coerce.date()` accepts the string itself, so this is a no-op for it. */
120
+ function coerceForField(a: ZodAdapter, field: ZodNode, value: unknown): unknown {
121
+ const { inner } = a.unwrap(field);
122
+ if (a.kind(inner) === 'date' && typeof value === 'string') {
123
+ const d = new Date(value);
124
+ if (!Number.isNaN(d.getTime())) return d;
125
+ }
126
+ return value;
127
+ }
128
+
129
+ /**
130
+ * A message for an unparseable date, in place of zod's own.
131
+ *
132
+ * `z.coerce.date()` runs `new Date(…)` before it type-checks, so an
133
+ * unparseable string reaches the check as an *Invalid Date* — an object of the
134
+ * right type — and zod reports "Invalid input: expected date, received Date".
135
+ * That is accurate about the internals and useless in a field error, so a date
136
+ * field is asked here first and zod is left to explain everything else.
137
+ *
138
+ * Returns `null` when the value is not a date problem, so the caller falls
139
+ * through to zod's message.
140
+ */
141
+ function invalidDateMessage(a: ZodAdapter, field: ZodNode, value: unknown): string | null {
142
+ if (a.kind(a.unwrap(field).inner) !== 'date') return null;
143
+ const d = value instanceof Date ? value : typeof value === 'string' ? new Date(value) : null;
144
+ return d && Number.isNaN(d.getTime()) ? 'not a date we can read' : null;
145
+ }
146
+
147
+ /**
148
+ * Validate changed frontmatter keys against the schema, per key. Returns a
149
+ * field→message map (empty when everything passes). Keys the schema doesn't
150
+ * know are allowed through — they're the user's extra data.
151
+ */
152
+ export function validateChanges(
153
+ schema: unknown,
154
+ changes: Record<string, unknown>,
155
+ ): Record<string, string> {
156
+ const a = adapterFor(schema);
157
+ const shape = shapeOf(schema);
158
+ const errors: Record<string, string> = {};
159
+ if (!a || !shape) return errors;
160
+ for (const [key, value] of Object.entries(changes)) {
161
+ const field = shape[key] as ZodNode | undefined;
162
+ if (value === null) {
163
+ // Deletion: the well-behaved client only sends null for optional fields,
164
+ // but don't trust it — a required key must not be strippable.
165
+ if (field && a.unwrap(field).required) errors[key] = 'required';
166
+ continue;
167
+ }
168
+ if (!field?.safeParse) continue;
169
+ const result = field.safeParse(coerceForField(a, field, value));
170
+ if (!result.success) {
171
+ errors[key] =
172
+ invalidDateMessage(a, field, value) ??
173
+ result.error?.issues?.[0]?.message ??
174
+ 'invalid value';
175
+ }
176
+ }
177
+ return errors;
178
+ }
179
+
180
+ /** Validate a complete frontmatter object (for /entry/create). */
181
+ export function validateFull(
182
+ schema: unknown,
183
+ values: Record<string, unknown>,
184
+ ): Record<string, string> {
185
+ const a = adapterFor(schema);
186
+ const shape = shapeOf(schema);
187
+ const errors: Record<string, string> = {};
188
+ if (!a || !shape) return errors;
189
+ for (const [key, field] of Object.entries(shape) as [string, ZodNode][]) {
190
+ if (!field?.safeParse) continue;
191
+ const has = key in values && values[key] !== undefined && values[key] !== '';
192
+ const result = field.safeParse(has ? coerceForField(a, field, values[key]) : undefined);
193
+ if (!result.success) {
194
+ errors[key] = has
195
+ ? (invalidDateMessage(a, field, values[key]) ??
196
+ result.error?.issues?.[0]?.message ??
197
+ 'invalid value')
198
+ : 'required';
199
+ }
200
+ }
201
+ return errors;
202
+ }
203
+
204
+ /** Fallback when no schema is resolvable: infer field types from the values
205
+ * actually present in the entry's frontmatter. Everything is optional. */
206
+ export function inferFields(data: Record<string, unknown>): FieldDescriptor[] {
207
+ return Object.entries(data).map(([name, value]) => ({
208
+ name,
209
+ label: humanize(name),
210
+ type: inferType(value),
211
+ required: false,
212
+ present: true,
213
+ source: 'inferred' as const,
214
+ }));
215
+ }
216
+
217
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
218
+
219
+ function inferType(value: unknown): FieldType {
220
+ switch (typeof value) {
221
+ case 'string':
222
+ if (DATE_RE.test(value)) return 'date';
223
+ return value.includes('\n') || value.length > 90 ? 'textarea' : 'text';
224
+ case 'number':
225
+ return 'number';
226
+ case 'boolean':
227
+ return 'boolean';
228
+ default:
229
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) return 'tags';
230
+ if (value instanceof Date) return 'date';
231
+ return 'json';
232
+ }
233
+ }