@saacms/admin 0.1.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/README.md +46 -0
- package/dist/api-client.d.ts +93 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/editor/AdminShell.d.ts +16 -0
- package/dist/editor/AdminShell.d.ts.map +1 -0
- package/dist/editor/PatternMapper.d.ts +120 -0
- package/dist/editor/PatternMapper.d.ts.map +1 -0
- package/dist/editor/SaacmsEditor.d.ts +29 -0
- package/dist/editor/SaacmsEditor.d.ts.map +1 -0
- package/dist/editor/nav.d.ts +27 -0
- package/dist/editor/nav.d.ts.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +634 -0
- package/dist/manifest.d.ts +26 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/mount.d.ts +27 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/puck-config.d.ts +32 -0
- package/dist/puck-config.d.ts.map +1 -0
- package/dist/puck-types.d.ts +24 -0
- package/dist/puck-types.d.ts.map +1 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @saacms/admin
|
|
2
|
+
|
|
3
|
+
**Status: placeholder (v0.1.0).** This package is intentionally empty.
|
|
4
|
+
|
|
5
|
+
The full admin UI scaffold lands in a follow-up implementation pass. When it
|
|
6
|
+
does, this package will become a Vite + React 19 application that ships as the
|
|
7
|
+
single editor experience across every host adapter (per ADR 0017: "the admin UI
|
|
8
|
+
is React + shadcn (Puck-driven), regardless of host").
|
|
9
|
+
|
|
10
|
+
## Planned shape
|
|
11
|
+
|
|
12
|
+
The follow-up will introduce, roughly in this order:
|
|
13
|
+
|
|
14
|
+
- **Vite + React 19** project, bundled into the host's build output and mounted
|
|
15
|
+
at `/admin/[[...slug]]` (or each host's equivalent route).
|
|
16
|
+
- **shadcn/ui** as the component primitive layer for the app shell, dialogs,
|
|
17
|
+
forms, and Performance / Cache-debug tabs.
|
|
18
|
+
- **Puck** as the visual block-tree composer for Pages and templates
|
|
19
|
+
(per ADR 0004 dual-mode authoring).
|
|
20
|
+
- **Monaco** as the lazily-loaded power-user fallback for raw Page-JSON,
|
|
21
|
+
Block-prop, and Collection-schema editing (per ADR 0022 §D5).
|
|
22
|
+
- **Better Auth UI** wrapped for the sign-in / multi-provider OAuth flow
|
|
23
|
+
(per ADR 0022 §D4).
|
|
24
|
+
- **Floating dev launcher** injected into the user's running site in dev mode
|
|
25
|
+
so the editor opens as an overlay over the current page rather than forcing a
|
|
26
|
+
navigation to `/admin` (per ADR 0022 §D2; adopted from Nuxt Studio).
|
|
27
|
+
- **Iframe-free real-time preview** — the editor's preview pane is the
|
|
28
|
+
production render path itself, via ADR 0004's preview-fetch + DOM-teleport
|
|
29
|
+
(per ADR 0022 §D3).
|
|
30
|
+
- **Editor UI internationalisation** wired from day one even though content
|
|
31
|
+
i18n is deferred per ADR 0016 (per ADR 0022 §D7).
|
|
32
|
+
|
|
33
|
+
## What's here today
|
|
34
|
+
|
|
35
|
+
Only `src/index.ts` with a marker constant and a `adminMountInfo()` function
|
|
36
|
+
that reports `{ route: "/admin", status: "placeholder" }`. This exists so that
|
|
37
|
+
the monorepo's project-references graph resolves and `tsc --build` stays
|
|
38
|
+
typecheck-clean while we sequence the real UI work.
|
|
39
|
+
|
|
40
|
+
No React, shadcn, Puck, Monaco, or Better Auth dependencies are pulled in yet
|
|
41
|
+
— they land alongside the actual implementation.
|
|
42
|
+
|
|
43
|
+
## Related ADRs
|
|
44
|
+
|
|
45
|
+
- [ADR 0017 — Monorepo package layout](../../docs/adr/0017-monorepo-package-layout.md)
|
|
46
|
+
- [ADR 0022 — Performance and DX philosophy](../../docs/adr/0022-performance-and-dx-philosophy.md)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin typed client over the saacms runtime HTTP API (the CRUD + OpenAPI
|
|
3
|
+
* routes built in prior rounds, mounted at `/api/saacms/v1`).
|
|
4
|
+
*
|
|
5
|
+
* Transport is injected (`fetchFn`, default global `fetch`) — same
|
|
6
|
+
* testability discipline as `cli/logs`: the whole client is unit-testable
|
|
7
|
+
* against canned `Response`s with no real network.
|
|
8
|
+
*
|
|
9
|
+
* Wire contracts mirrored here (all owned by `@saacms/core/runtime`):
|
|
10
|
+
* - List → `{ data: [], _links: { next: { href } | null }, _meta }`
|
|
11
|
+
* - Read/Update → `{ data, _links }` ; Create (201) → `{ data: { …, id } }`
|
|
12
|
+
* - Errors → RFC 9457 `application/problem+json`: `{ type, title, status,
|
|
13
|
+
* detail?, … }` where `type` is `https://saacms.dev/errors/<code>` and
|
|
14
|
+
* `extensions` (e.g. `issues`) are merged flat at the top level.
|
|
15
|
+
*/
|
|
16
|
+
/** Error thrown for non-success runtime responses (carries Problem Details). */
|
|
17
|
+
export declare class AdminApiError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
/** Last path segment of the Problem Details `type` URI (e.g. `forbidden`). */
|
|
20
|
+
readonly code: string;
|
|
21
|
+
/** Structured validation issues from a 422 Problem Details document. */
|
|
22
|
+
readonly issues?: unknown;
|
|
23
|
+
constructor(message: string, init: {
|
|
24
|
+
status: number;
|
|
25
|
+
code: string;
|
|
26
|
+
issues?: unknown;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export interface AdminApiClientOptions {
|
|
30
|
+
/** API base path. Default `/api/saacms/v1`. */
|
|
31
|
+
readonly basePath?: string;
|
|
32
|
+
/** Injected transport; defaults to the global `fetch`. */
|
|
33
|
+
readonly fetchFn?: typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
export interface ListOpts {
|
|
36
|
+
readonly limit?: number;
|
|
37
|
+
readonly cursor?: string;
|
|
38
|
+
readonly sort?: string;
|
|
39
|
+
readonly filter?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface ListResult {
|
|
42
|
+
readonly data: ReadonlyArray<unknown>;
|
|
43
|
+
readonly next: string | null;
|
|
44
|
+
}
|
|
45
|
+
export declare class AdminApiClient {
|
|
46
|
+
private readonly basePath;
|
|
47
|
+
private readonly fetchFn;
|
|
48
|
+
constructor(options?: AdminApiClientOptions);
|
|
49
|
+
private url;
|
|
50
|
+
/** GET a collection list; parses the HATEOAS envelope. */
|
|
51
|
+
list(collection: string, opts?: ListOpts): Promise<ListResult>;
|
|
52
|
+
/** GET a single record. 404 → `null`; other non-2xx → throw. */
|
|
53
|
+
get(collection: string, id: string): Promise<unknown | null>;
|
|
54
|
+
/** POST a new record. 201 → `{ id, data }`; 422 → throw with `issues`. */
|
|
55
|
+
create(collection: string, body: unknown): Promise<{
|
|
56
|
+
id: string;
|
|
57
|
+
data: unknown;
|
|
58
|
+
}>;
|
|
59
|
+
/** PATCH a record. Sends `If-Match` when `etag` given; 412 → throw. */
|
|
60
|
+
update(collection: string, id: string, patch: unknown, etag?: string): Promise<unknown>;
|
|
61
|
+
/** DELETE a record (204 expected). Sends `If-Match` when `etag` given. */
|
|
62
|
+
remove(collection: string, id: string, etag?: string): Promise<void>;
|
|
63
|
+
/**
|
|
64
|
+
* Save (upsert) the Draft tree for a Page — `PUT /drafts/:pageId`. This is
|
|
65
|
+
* *Save draft*, NOT Publish (Publish stays the deliberate `saacms publish`).
|
|
66
|
+
* Sends `If-Match` when `etag` is given (optimistic concurrency); a stale
|
|
67
|
+
* tag → the runtime's 412 surfaces as an `AdminApiError` (`code:
|
|
68
|
+
* "precondition-failed"`), a malformed tree → 422 with `issues`.
|
|
69
|
+
* Returns the stored Draft `data` and the fresh weak `ETag`.
|
|
70
|
+
*/
|
|
71
|
+
saveDraft(pageId: string, tree: unknown, etag?: string): Promise<{
|
|
72
|
+
data: unknown;
|
|
73
|
+
etag: string | null;
|
|
74
|
+
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Load the current Draft tree for a Page — `GET /drafts/:pageId`.
|
|
77
|
+
* 404 → `null` (no Draft, or not accessible — non-enumeration); other
|
|
78
|
+
* non-2xx → throw. Returns the Draft `data` + its weak `ETag`.
|
|
79
|
+
*/
|
|
80
|
+
loadDraft(pageId: string): Promise<{
|
|
81
|
+
data: unknown;
|
|
82
|
+
etag: string | null;
|
|
83
|
+
} | null>;
|
|
84
|
+
/**
|
|
85
|
+
* Discard the Draft for a Page (revert to last Published) —
|
|
86
|
+
* `DELETE /drafts/:pageId` (204 expected, idempotent). Sends `If-Match`
|
|
87
|
+
* when `etag` is given.
|
|
88
|
+
*/
|
|
89
|
+
discardDraft(pageId: string, etag?: string): Promise<void>;
|
|
90
|
+
/** GET the runtime's OpenAPI document. */
|
|
91
|
+
openapi(): Promise<unknown>;
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=api-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-client.d.ts","sourceRoot":"","sources":["../src/api-client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,gFAAgF;AAChF,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8EAA8E;IAC9E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,wEAAwE;IACxE,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;gBAGvB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE;CAQ3D;AAED,MAAM,WAAW,qBAAqB;IACpC,+CAA+C;IAC/C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,0DAA0D;IAC1D,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAA;CAChC;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACzB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAoDD,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAQ;IACjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAc;gBAE1B,OAAO,GAAE,qBAA0B;IAK/C,OAAO,CAAC,GAAG;IAWX,0DAA0D;IACpD,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,GAAE,QAAa,GAAG,OAAO,CAAC,UAAU,CAAC;IAqBxE,gEAAgE;IAC1D,GAAG,CAAC,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAQlE,0EAA0E;IACpE,MAAM,CACV,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,OAAO,GACZ,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC;IAazC,uEAAuE;IACjE,MAAM,CACV,UAAU,EAAE,MAAM,EAClB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,OAAO,EACd,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC;IAgBnB,0EAA0E;IACpE,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAa1E;;;;;;;OAOG;IACG,SAAS,CACb,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,OAAO,EACb,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IAmBlD;;;;OAIG;IACG,SAAS,CACb,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;IAWzD;;;;OAIG;IACG,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAahE,0CAA0C;IACpC,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC;CAKlC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @saacms/admin v0.2 — outer admin shell (THIN React glue).
|
|
3
|
+
*
|
|
4
|
+
* Left nav from the pure {@link ./nav.ts} `buildNavModel` selector; routes
|
|
5
|
+
* between a Collection list view and {@link ./SaacmsEditor.tsx}. No testable
|
|
6
|
+
* logic lives here — list/manifest/nav logic is the v0.1 layer + the pure
|
|
7
|
+
* selector (both unit-tested). Validated by `tsc --build` only.
|
|
8
|
+
*/
|
|
9
|
+
import type { SaacmsConfig } from "@saacms/core";
|
|
10
|
+
import { AdminApiClient } from "../api-client.ts";
|
|
11
|
+
export interface AdminShellProps {
|
|
12
|
+
readonly config: SaacmsConfig;
|
|
13
|
+
readonly api?: AdminApiClient;
|
|
14
|
+
}
|
|
15
|
+
export declare function AdminShell(props: AdminShellProps): JSX.Element;
|
|
16
|
+
//# sourceMappingURL=AdminShell.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AdminShell.d.ts","sourceRoot":"","sources":["../../src/editor/AdminShell.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAKjD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,QAAQ,CAAC,GAAG,CAAC,EAAE,cAAc,CAAA;CAC9B;AAoDD,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,GAAG,GAAG,CAAC,OAAO,CA4D9D"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @saacms/admin v0.2 — PURE data bridge between Puck and the saacms runtime.
|
|
3
|
+
*
|
|
4
|
+
* THIS is where the real editor logic lives, and where ≥90% of the test
|
|
5
|
+
* coverage sits. Per the package's structural-typing discipline (and because
|
|
6
|
+
* bun:test has no DOM and the repo bans jsdom/RTL), every non-trivial
|
|
7
|
+
* transformation is a pure, side-effect-free function unit-tested against
|
|
8
|
+
* plain objects + a mock `AdminApiClient`. The React/Puck `.tsx` files are
|
|
9
|
+
* thin glue validated only by `tsc --build`.
|
|
10
|
+
*
|
|
11
|
+
* Wire shapes
|
|
12
|
+
* -----------
|
|
13
|
+
* Puck `Data` (structurally; we do not import `@measured/puck` here):
|
|
14
|
+
* { root: { props?: {...} } | {...flat}, content: PuckNode[], zones? }
|
|
15
|
+
* PuckNode = { type: string, props: { id: string, ...rest } }
|
|
16
|
+
*
|
|
17
|
+
* saacms draft tree — the runtime/Pattern API's stored layout shape (Pages
|
|
18
|
+
* carry `layout?: unknown` "Stored Puck tree JSON … validated at runtime";
|
|
19
|
+
* ADR 0025 Patterns are stored content `{ name, tree }`). We normalise to a
|
|
20
|
+
* stable canonical tree so it diffs cleanly and round-trips:
|
|
21
|
+
* { root: { props: {...} }, nodes: SaacmsNode[] }
|
|
22
|
+
* SaacmsNode = { block: string, id: string, props: {...} }
|
|
23
|
+
*/
|
|
24
|
+
import type { AdminApiClient } from "../api-client.ts";
|
|
25
|
+
import type { PuckConfig } from "../puck-types.ts";
|
|
26
|
+
export interface PuckNode {
|
|
27
|
+
readonly type: string;
|
|
28
|
+
readonly props: Readonly<Record<string, unknown>> & {
|
|
29
|
+
readonly id?: unknown;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export interface PuckData {
|
|
33
|
+
readonly root: Readonly<Record<string, unknown>>;
|
|
34
|
+
readonly content: ReadonlyArray<PuckNode>;
|
|
35
|
+
readonly zones?: Readonly<Record<string, ReadonlyArray<PuckNode>>>;
|
|
36
|
+
}
|
|
37
|
+
export interface SaacmsNode {
|
|
38
|
+
readonly block: string;
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
41
|
+
}
|
|
42
|
+
export interface SaacmsDraftTree {
|
|
43
|
+
readonly root: {
|
|
44
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
45
|
+
};
|
|
46
|
+
readonly nodes: ReadonlyArray<SaacmsNode>;
|
|
47
|
+
}
|
|
48
|
+
export interface SaacmsDraft {
|
|
49
|
+
readonly tree: SaacmsDraftTree;
|
|
50
|
+
}
|
|
51
|
+
/** Map Puck `Data` → the canonical saacms draft tree. */
|
|
52
|
+
export declare function puckDataToDraft(puckData: PuckData): SaacmsDraft;
|
|
53
|
+
/**
|
|
54
|
+
* Inverse of {@link puckDataToDraft}. `puckConfig` is consulted to drop nodes
|
|
55
|
+
* whose block type the editor cannot render (defensive — a stale Pattern may
|
|
56
|
+
* reference a Block the Developer has since removed); the round-trip stays
|
|
57
|
+
* structurally stable for any tree whose blocks are all registered.
|
|
58
|
+
*/
|
|
59
|
+
export declare function draftToPuckData(draft: SaacmsDraft, puckConfig: PuckConfig): PuckData;
|
|
60
|
+
export type EditorSaveErrorCode = "validation" | "precondition-failed" | "forbidden" | "not-found" | "empty-selection" | "unknown";
|
|
61
|
+
/**
|
|
62
|
+
* The single typed failure the editor surfaces. `puckOnPublishHandler` /
|
|
63
|
+
* `savePatternHandler` translate an `AdminApiError` (or a guard failure) into
|
|
64
|
+
* this; `formatSaveError` turns it into Owner-facing copy.
|
|
65
|
+
*/
|
|
66
|
+
export declare class EditorSaveError extends Error {
|
|
67
|
+
readonly code: EditorSaveErrorCode;
|
|
68
|
+
readonly status?: number;
|
|
69
|
+
readonly issues?: unknown;
|
|
70
|
+
constructor(message: string, init: {
|
|
71
|
+
code: EditorSaveErrorCode;
|
|
72
|
+
status?: number;
|
|
73
|
+
issues?: unknown;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export interface PublishDeps {
|
|
77
|
+
readonly api: AdminApiClient;
|
|
78
|
+
readonly collection: string;
|
|
79
|
+
/** Present → PATCH that record; absent → POST a new one. */
|
|
80
|
+
readonly id?: string;
|
|
81
|
+
/** Optimistic-concurrency tag forwarded as `If-Match` on update. */
|
|
82
|
+
readonly etag?: string;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Build the async publish handler the editor hands to `<Puck onPublish>`.
|
|
86
|
+
* Pure w.r.t. the injected `api` (mock it in tests). Maps the Puck tree to a
|
|
87
|
+
* draft, then create-or-updates; any `AdminApiError` becomes a typed
|
|
88
|
+
* {@link EditorSaveError} (422 keeps `.issues`, 412 → `precondition-failed`).
|
|
89
|
+
*/
|
|
90
|
+
export declare function puckOnPublishHandler(deps: PublishDeps): (puckData: PuckData) => Promise<{
|
|
91
|
+
id: string;
|
|
92
|
+
}>;
|
|
93
|
+
export interface PatternSelection {
|
|
94
|
+
/** Owner-given Pattern name. */
|
|
95
|
+
readonly name: string;
|
|
96
|
+
/** The selected Puck content sub-tree (the chosen Block instances). */
|
|
97
|
+
readonly nodes: ReadonlyArray<PuckNode>;
|
|
98
|
+
/** Optional preset root props for the saved arrangement. */
|
|
99
|
+
readonly root?: Readonly<Record<string, unknown>>;
|
|
100
|
+
}
|
|
101
|
+
export interface SavePatternDeps {
|
|
102
|
+
readonly api: AdminApiClient;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Map a selected sub-tree to a draft tree and `POST /patterns` (a Pattern is
|
|
106
|
+
* stored content `{ name, tree }`, persisted via the same row API as any
|
|
107
|
+
* Collection record — ADR 0025 Zone 1). Pure w.r.t. `deps.api`.
|
|
108
|
+
*
|
|
109
|
+
* Rejects with an `empty-selection` {@link EditorSaveError} when the name is
|
|
110
|
+
* blank or nothing is selected — nothing is sent to the server.
|
|
111
|
+
*/
|
|
112
|
+
export declare function savePatternHandler(deps: SavePatternDeps, selection: PatternSelection): Promise<{
|
|
113
|
+
id: string;
|
|
114
|
+
}>;
|
|
115
|
+
/**
|
|
116
|
+
* Human, Owner-facing one-liner for a save failure. Never leaks stack traces
|
|
117
|
+
* or server internals — the Owner never leaves the editor (ADR 0025).
|
|
118
|
+
*/
|
|
119
|
+
export declare function formatSaveError(err: EditorSaveError): string;
|
|
120
|
+
//# sourceMappingURL=PatternMapper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PatternMapper.d.ts","sourceRoot":"","sources":["../../src/editor/PatternMapper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAMlD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG;QAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;CAC9E;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IAChD,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAA;IACzC,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAA;CACnE;AAMD,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CAClD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;KAAE,CAAA;IACpE,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,UAAU,CAAC,CAAA;CAC1C;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAC/B;AA2BD,yDAAyD;AACzD,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW,CAe/D;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,WAAW,EAClB,UAAU,EAAE,UAAU,GACrB,QAAQ,CAUV;AAMD,MAAM,MAAM,mBAAmB,GAC3B,YAAY,GACZ,qBAAqB,GACrB,WAAW,GACX,WAAW,GACX,iBAAiB,GACjB,SAAS,CAAA;AAEb;;;;GAIG;AACH,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAA;IAClC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;gBAGvB,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;QAAE,IAAI,EAAE,mBAAmB,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE;CAQzE;AA2CD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAA;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;IACpB,oEAAoE;IACpE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,WAAW,GAChB,CAAC,QAAQ,EAAE,QAAQ,KAAK,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CAcjD;AAMD,MAAM,WAAW,gBAAgB;IAC/B,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,uEAAuE;IACvE,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAA;IACvC,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CAClD;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAA;CAC7B;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,eAAe,EACrB,SAAS,EAAE,gBAAgB,GAC1B,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CAmBzB;AAsBD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAmB5D"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @saacms/admin v0.2 — the Puck editor surface (THIN React glue).
|
|
3
|
+
*
|
|
4
|
+
* This component contains no testable logic: it wires the v0.1 headless layer
|
|
5
|
+
* (`buildPuckConfig`) + the pure {@link ./PatternMapper.ts} bridge into
|
|
6
|
+
* `<Puck>`. It is validated by `tsc --build` only — bun:test has no DOM and
|
|
7
|
+
* the repo bans jsdom/RTL, so React is never rendered in a unit test (see the
|
|
8
|
+
* package header and ADR 0022 §"test strategy"). All branching logic it needs
|
|
9
|
+
* is delegated to pure, unit-tested functions in `PatternMapper.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Data } from "@measured/puck";
|
|
12
|
+
import type { SaacmsConfig } from "@saacms/core";
|
|
13
|
+
import { AdminApiClient } from "../api-client.ts";
|
|
14
|
+
export interface SaacmsEditorProps {
|
|
15
|
+
readonly config: SaacmsConfig;
|
|
16
|
+
readonly collection: string;
|
|
17
|
+
readonly recordId?: string;
|
|
18
|
+
readonly api: AdminApiClient;
|
|
19
|
+
/**
|
|
20
|
+
* Page id this editor is editing. When present, the "Save draft"
|
|
21
|
+
* affordance persists the current tree to `PUT /drafts/:pageId` — this is
|
|
22
|
+
* *Save draft* (free, no CI), NOT Publish.
|
|
23
|
+
*/
|
|
24
|
+
readonly pageId?: string;
|
|
25
|
+
/** Initial Puck tree (e.g. fetched record layout). Defaults to empty. */
|
|
26
|
+
readonly initialData?: Partial<Data>;
|
|
27
|
+
}
|
|
28
|
+
export declare function SaacmsEditor(props: SaacmsEditorProps): JSX.Element;
|
|
29
|
+
//# sourceMappingURL=SaacmsEditor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SaacmsEditor.d.ts","sourceRoot":"","sources":["../../src/editor/SaacmsEditor.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAU,IAAI,EAAE,MAAM,gBAAgB,CAAA;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAUjD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,GAAG,EAAE,cAAc,CAAA;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,yEAAyE;IACzE,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CACrC;AAqCD,wBAAgB,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,GAAG,CAAC,OAAO,CAkGlE"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @saacms/admin v0.2 — PURE nav selector for the admin shell.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own React/Puck-free module so `buildNavModel` is unit-testable
|
|
5
|
+
* in bun:test without dragging in the DOM (the shell `.tsx` imports it; tests
|
|
6
|
+
* import only this file). Collection labels reuse the v0.1 `adminManifest`
|
|
7
|
+
* projection (`CollectionDef.label ?? PascalCase(slug)`) — single source of
|
|
8
|
+
* truth, not re-derived.
|
|
9
|
+
*/
|
|
10
|
+
import type { SaacmsConfig } from "@saacms/core";
|
|
11
|
+
export type NavEntryKind = "collection" | "patterns";
|
|
12
|
+
export interface NavEntry {
|
|
13
|
+
readonly kind: NavEntryKind;
|
|
14
|
+
/** Route segment under `/admin/` (collection slug, or `patterns`). */
|
|
15
|
+
readonly slug: string;
|
|
16
|
+
readonly label: string;
|
|
17
|
+
}
|
|
18
|
+
export interface NavModel {
|
|
19
|
+
readonly entries: ReadonlyArray<NavEntry>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Left-nav model: one entry per Collection (labelled exactly as the manifest
|
|
23
|
+
* does) followed by the always-present Patterns entry. Zero collections →
|
|
24
|
+
* just the Patterns entry (no throw).
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildNavModel(config: SaacmsConfig): NavModel;
|
|
27
|
+
//# sourceMappingURL=nav.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nav.d.ts","sourceRoot":"","sources":["../../src/editor/nav.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,MAAM,MAAM,YAAY,GAAG,YAAY,GAAG,UAAU,CAAA;AAEpD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAA;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAA;CAC1C;AASD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,QAAQ,CAQ5D"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @saacms/admin — v0.2: the real Puck + React editor shell.
|
|
3
|
+
*
|
|
4
|
+
* v0.1 was the pure, testable headless data layer (`buildPuckConfig` /
|
|
5
|
+
* `AdminApiClient` / `adminManifest`). v0.2 mounts the actual editor on top
|
|
6
|
+
* of it — every export below is still here (full back-compat) plus:
|
|
7
|
+
*
|
|
8
|
+
* - `SaacmsEditor` / `AdminShell` / `mountAdmin` — the React + `<Puck>`
|
|
9
|
+
* shell the host's `/admin/*` route renders.
|
|
10
|
+
* - `puckDataToDraft` / `draftToPuckData` / `puckOnPublishHandler` /
|
|
11
|
+
* `savePatternHandler` / `formatSaveError` / `buildNavModel` — the PURE,
|
|
12
|
+
* fully unit-tested bridge where all the real logic lives.
|
|
13
|
+
*
|
|
14
|
+
* Test strategy (deliberate): bun:test has no DOM and the repo bans
|
|
15
|
+
* jsdom/RTL, so **all non-trivial logic is in pure functions** (the v0.1
|
|
16
|
+
* discipline) covered by `editor.test.ts`; the React/Puck `.tsx` files are
|
|
17
|
+
* THIN glue validated by `tsc --build` + a successful build, never by
|
|
18
|
+
* rendered unit tests. Same structural-typing rule the codebase uses for
|
|
19
|
+
* platform libs.
|
|
20
|
+
*
|
|
21
|
+
* The placeholder surface (`adminMountInfo` / `AdminMountInfo` /
|
|
22
|
+
* `AdminMountStatus` / `adminPlaceholderVersion`) is kept exported for
|
|
23
|
+
* back-compat with anything already importing it.
|
|
24
|
+
*/
|
|
25
|
+
export type { PuckConfig, PuckComponentConfig } from "./puck-types.ts";
|
|
26
|
+
export { buildPuckConfig, pascalCase } from "./puck-config.ts";
|
|
27
|
+
export { AdminApiClient, AdminApiError, type AdminApiClientOptions, type ListOpts, type ListResult, } from "./api-client.ts";
|
|
28
|
+
export { adminManifest, ADMIN_ROUTE, type AdminManifest } from "./manifest.ts";
|
|
29
|
+
export { puckDataToDraft, draftToPuckData, puckOnPublishHandler, savePatternHandler, formatSaveError, EditorSaveError, type PuckData, type PuckNode, type SaacmsDraft, type SaacmsDraftTree, type SaacmsNode, type EditorSaveErrorCode, type PublishDeps, type SavePatternDeps, type PatternSelection, } from "./editor/PatternMapper.ts";
|
|
30
|
+
export { buildNavModel, type NavModel, type NavEntry, type NavEntryKind, } from "./editor/nav.ts";
|
|
31
|
+
export { SaacmsEditor, type SaacmsEditorProps } from "./editor/SaacmsEditor.tsx";
|
|
32
|
+
export { AdminShell, type AdminShellProps } from "./editor/AdminShell.tsx";
|
|
33
|
+
export { mountAdmin, type MountAdminOptions, type AdminHandle, } from "./mount.tsx";
|
|
34
|
+
export declare const adminPlaceholderVersion: "0.1.0-placeholder";
|
|
35
|
+
export type AdminMountStatus = "placeholder";
|
|
36
|
+
export interface AdminMountInfo {
|
|
37
|
+
readonly route: string;
|
|
38
|
+
readonly status: AdminMountStatus;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Back-compat shim. Delegates to the admin route constant; richer surface is
|
|
42
|
+
* `adminManifest(config)`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function adminMountInfo(): AdminMountInfo;
|
|
45
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAGH,YAAY,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAA;AACtE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAC9D,OAAO,EACL,cAAc,EACd,aAAa,EACb,KAAK,qBAAqB,EAC1B,KAAK,QAAQ,EACb,KAAK,UAAU,GAChB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAA;AAG9E,OAAO,EACL,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,eAAe,EACf,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,mBAAmB,EACxB,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,gBAAgB,GACtB,MAAM,2BAA2B,CAAA;AAClC,OAAO,EACL,aAAa,EACb,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,YAAY,GAClB,MAAM,iBAAiB,CAAA;AAGxB,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAChF,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAC1E,OAAO,EACL,UAAU,EACV,KAAK,iBAAiB,EACtB,KAAK,WAAW,GACjB,MAAM,aAAa,CAAA;AAQpB,eAAO,MAAM,uBAAuB,EAAG,mBAA4B,CAAA;AAEnE,MAAM,MAAM,gBAAgB,GAAG,aAAa,CAAA;AAE5C,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAA;CAClC;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,cAAc,CAE/C"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// src/puck-config.ts
|
|
2
|
+
import { schemaToPuckFields } from "@saacms/core";
|
|
3
|
+
function pascalCase(slug) {
|
|
4
|
+
return slug.split(/[-_\s]+/).filter((t) => t.length > 0).map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join("");
|
|
5
|
+
}
|
|
6
|
+
function isAstNode(v) {
|
|
7
|
+
return typeof v === "object" && v !== null && typeof v._tag === "string";
|
|
8
|
+
}
|
|
9
|
+
function isTypeLiteral(n) {
|
|
10
|
+
return n._tag === "TypeLiteral" && Array.isArray(n.propertySignatures);
|
|
11
|
+
}
|
|
12
|
+
function peelRefinements(n) {
|
|
13
|
+
let t = n;
|
|
14
|
+
while (t._tag === "Refinement" && isAstNode(t.from)) {
|
|
15
|
+
t = t.from;
|
|
16
|
+
}
|
|
17
|
+
return t;
|
|
18
|
+
}
|
|
19
|
+
function schemaAst(schema) {
|
|
20
|
+
const ast = schema.ast;
|
|
21
|
+
return isAstNode(ast) ? ast : undefined;
|
|
22
|
+
}
|
|
23
|
+
function deriveDefaultProps(blockSchema) {
|
|
24
|
+
const root = schemaAst(blockSchema);
|
|
25
|
+
if (root === undefined || !isTypeLiteral(root))
|
|
26
|
+
return;
|
|
27
|
+
const out = {};
|
|
28
|
+
for (const ps of root.propertySignatures) {
|
|
29
|
+
if (ps.isOptional)
|
|
30
|
+
continue;
|
|
31
|
+
const base = peelRefinements(ps.type);
|
|
32
|
+
const key = String(ps.name);
|
|
33
|
+
if (base._tag === "StringKeyword")
|
|
34
|
+
out[key] = "";
|
|
35
|
+
else if (base._tag === "NumberKeyword")
|
|
36
|
+
out[key] = 0;
|
|
37
|
+
else if (base._tag === "BooleanKeyword")
|
|
38
|
+
out[key] = false;
|
|
39
|
+
}
|
|
40
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
41
|
+
}
|
|
42
|
+
function buildPuckConfig(config) {
|
|
43
|
+
const blocks = config.blocks ?? [];
|
|
44
|
+
const entries = blocks.map((block) => {
|
|
45
|
+
const collShaped = { slug: block.slug, schema: block.schema };
|
|
46
|
+
const fields = schemaToPuckFields(collShaped);
|
|
47
|
+
const defaultProps = deriveDefaultProps(block.schema);
|
|
48
|
+
const component = defaultProps !== undefined ? { fields, defaultProps } : { fields };
|
|
49
|
+
return [pascalCase(block.slug), component];
|
|
50
|
+
}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
51
|
+
const components = {};
|
|
52
|
+
for (const [key, component] of entries)
|
|
53
|
+
components[key] = component;
|
|
54
|
+
return { components, root: { fields: {} } };
|
|
55
|
+
}
|
|
56
|
+
// src/api-client.ts
|
|
57
|
+
class AdminApiError extends Error {
|
|
58
|
+
status;
|
|
59
|
+
code;
|
|
60
|
+
issues;
|
|
61
|
+
constructor(message, init) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "AdminApiError";
|
|
64
|
+
this.status = init.status;
|
|
65
|
+
this.code = init.code;
|
|
66
|
+
if (init.issues !== undefined)
|
|
67
|
+
this.issues = init.issues;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
var DEFAULT_BASE_PATH = "/api/saacms/v1";
|
|
71
|
+
var STATUS_CODE = {
|
|
72
|
+
401: "unauthorized",
|
|
73
|
+
403: "forbidden",
|
|
74
|
+
404: "not-found",
|
|
75
|
+
409: "conflict",
|
|
76
|
+
412: "precondition-failed",
|
|
77
|
+
415: "unsupported-media-type",
|
|
78
|
+
422: "unprocessable-entity",
|
|
79
|
+
500: "internal-error"
|
|
80
|
+
};
|
|
81
|
+
function isRecord(v) {
|
|
82
|
+
return typeof v === "object" && v !== null;
|
|
83
|
+
}
|
|
84
|
+
function codeFromProblem(body, status) {
|
|
85
|
+
if (isRecord(body) && typeof body.type === "string") {
|
|
86
|
+
const seg = body.type.split("/").filter((s) => s.length > 0).pop();
|
|
87
|
+
if (seg !== undefined && seg.length > 0)
|
|
88
|
+
return seg;
|
|
89
|
+
}
|
|
90
|
+
return STATUS_CODE[status] ?? "error";
|
|
91
|
+
}
|
|
92
|
+
async function readProblem(res) {
|
|
93
|
+
try {
|
|
94
|
+
return await res.json();
|
|
95
|
+
} catch {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function errorFrom(res) {
|
|
100
|
+
const body = await readProblem(res);
|
|
101
|
+
const code = codeFromProblem(body, res.status);
|
|
102
|
+
const title = isRecord(body) && typeof body.title === "string" ? body.title : `Request failed with status ${res.status}`;
|
|
103
|
+
const issues = isRecord(body) ? body.issues ?? (isRecord(body.extensions) ? body.extensions.issues : undefined) : undefined;
|
|
104
|
+
return new AdminApiError(title, { status: res.status, code, issues });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
class AdminApiClient {
|
|
108
|
+
basePath;
|
|
109
|
+
fetchFn;
|
|
110
|
+
constructor(options = {}) {
|
|
111
|
+
this.basePath = options.basePath ?? DEFAULT_BASE_PATH;
|
|
112
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
113
|
+
}
|
|
114
|
+
url(path, query) {
|
|
115
|
+
let u = `${this.basePath}${path}`;
|
|
116
|
+
if (query !== undefined) {
|
|
117
|
+
const qs = Object.entries(query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
|
|
118
|
+
if (qs.length > 0)
|
|
119
|
+
u += `?${qs}`;
|
|
120
|
+
}
|
|
121
|
+
return u;
|
|
122
|
+
}
|
|
123
|
+
async list(collection, opts = {}) {
|
|
124
|
+
const query = {};
|
|
125
|
+
if (opts.limit !== undefined)
|
|
126
|
+
query.limit = String(opts.limit);
|
|
127
|
+
if (opts.cursor !== undefined)
|
|
128
|
+
query.cursor = opts.cursor;
|
|
129
|
+
if (opts.sort !== undefined)
|
|
130
|
+
query.sort = opts.sort;
|
|
131
|
+
if (opts.filter !== undefined)
|
|
132
|
+
query.filter = opts.filter;
|
|
133
|
+
const res = await this.fetchFn(this.url(`/${collection}`, query));
|
|
134
|
+
if (!res.ok)
|
|
135
|
+
throw await errorFrom(res);
|
|
136
|
+
const body = await res.json();
|
|
137
|
+
const data = isRecord(body) && Array.isArray(body.data) ? body.data : [];
|
|
138
|
+
let next = null;
|
|
139
|
+
if (isRecord(body) && isRecord(body._links)) {
|
|
140
|
+
const n = body._links.next;
|
|
141
|
+
if (isRecord(n) && typeof n.href === "string")
|
|
142
|
+
next = n.href;
|
|
143
|
+
}
|
|
144
|
+
return { data, next };
|
|
145
|
+
}
|
|
146
|
+
async get(collection, id) {
|
|
147
|
+
const res = await this.fetchFn(this.url(`/${collection}/${id}`));
|
|
148
|
+
if (res.status === 404)
|
|
149
|
+
return null;
|
|
150
|
+
if (!res.ok)
|
|
151
|
+
throw await errorFrom(res);
|
|
152
|
+
const body = await res.json();
|
|
153
|
+
return isRecord(body) ? body.data ?? null : null;
|
|
154
|
+
}
|
|
155
|
+
async create(collection, body) {
|
|
156
|
+
const res = await this.fetchFn(this.url(`/${collection}`), {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: { "Content-Type": "application/json" },
|
|
159
|
+
body: JSON.stringify(body)
|
|
160
|
+
});
|
|
161
|
+
if (!res.ok)
|
|
162
|
+
throw await errorFrom(res);
|
|
163
|
+
const env = await res.json();
|
|
164
|
+
const data = isRecord(env) ? env.data : undefined;
|
|
165
|
+
const id = isRecord(data) ? String(data.id) : "";
|
|
166
|
+
return { id, data };
|
|
167
|
+
}
|
|
168
|
+
async update(collection, id, patch, etag) {
|
|
169
|
+
const headers = {
|
|
170
|
+
"Content-Type": "application/json"
|
|
171
|
+
};
|
|
172
|
+
if (etag !== undefined)
|
|
173
|
+
headers["If-Match"] = etag;
|
|
174
|
+
const res = await this.fetchFn(this.url(`/${collection}/${id}`), {
|
|
175
|
+
method: "PATCH",
|
|
176
|
+
headers,
|
|
177
|
+
body: JSON.stringify(patch)
|
|
178
|
+
});
|
|
179
|
+
if (!res.ok)
|
|
180
|
+
throw await errorFrom(res);
|
|
181
|
+
const body = await res.json();
|
|
182
|
+
return isRecord(body) ? body.data : undefined;
|
|
183
|
+
}
|
|
184
|
+
async remove(collection, id, etag) {
|
|
185
|
+
const headers = {};
|
|
186
|
+
if (etag !== undefined)
|
|
187
|
+
headers["If-Match"] = etag;
|
|
188
|
+
const res = await this.fetchFn(this.url(`/${collection}/${id}`), {
|
|
189
|
+
method: "DELETE",
|
|
190
|
+
headers
|
|
191
|
+
});
|
|
192
|
+
if (res.status === 204)
|
|
193
|
+
return;
|
|
194
|
+
if (!res.ok)
|
|
195
|
+
throw await errorFrom(res);
|
|
196
|
+
}
|
|
197
|
+
async saveDraft(pageId, tree, etag) {
|
|
198
|
+
const headers = {
|
|
199
|
+
"Content-Type": "application/json"
|
|
200
|
+
};
|
|
201
|
+
if (etag !== undefined)
|
|
202
|
+
headers["If-Match"] = etag;
|
|
203
|
+
const res = await this.fetchFn(this.url(`/drafts/${pageId}`), {
|
|
204
|
+
method: "PUT",
|
|
205
|
+
headers,
|
|
206
|
+
body: JSON.stringify(tree)
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok)
|
|
209
|
+
throw await errorFrom(res);
|
|
210
|
+
const body = await res.json();
|
|
211
|
+
return {
|
|
212
|
+
data: isRecord(body) ? body.data ?? null : null,
|
|
213
|
+
etag: res.headers.get("ETag")
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async loadDraft(pageId) {
|
|
217
|
+
const res = await this.fetchFn(this.url(`/drafts/${pageId}`));
|
|
218
|
+
if (res.status === 404)
|
|
219
|
+
return null;
|
|
220
|
+
if (!res.ok)
|
|
221
|
+
throw await errorFrom(res);
|
|
222
|
+
const body = await res.json();
|
|
223
|
+
return {
|
|
224
|
+
data: isRecord(body) ? body.data ?? null : null,
|
|
225
|
+
etag: res.headers.get("ETag")
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async discardDraft(pageId, etag) {
|
|
229
|
+
const headers = {};
|
|
230
|
+
if (etag !== undefined)
|
|
231
|
+
headers["If-Match"] = etag;
|
|
232
|
+
const res = await this.fetchFn(this.url(`/drafts/${pageId}`), {
|
|
233
|
+
method: "DELETE",
|
|
234
|
+
headers
|
|
235
|
+
});
|
|
236
|
+
if (res.status === 204)
|
|
237
|
+
return;
|
|
238
|
+
if (!res.ok)
|
|
239
|
+
throw await errorFrom(res);
|
|
240
|
+
}
|
|
241
|
+
async openapi() {
|
|
242
|
+
const res = await this.fetchFn(this.url(`/openapi.json`));
|
|
243
|
+
if (!res.ok)
|
|
244
|
+
throw await errorFrom(res);
|
|
245
|
+
return await res.json();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// src/manifest.ts
|
|
249
|
+
var ADMIN_ROUTE = "/admin";
|
|
250
|
+
function adminManifest(config) {
|
|
251
|
+
const collections = (config.collections ?? []).map((c) => ({
|
|
252
|
+
slug: c.slug,
|
|
253
|
+
label: c.label ?? pascalCase(c.slug)
|
|
254
|
+
}));
|
|
255
|
+
const blocks = (config.blocks ?? []).map((b) => ({ slug: b.slug }));
|
|
256
|
+
return { route: ADMIN_ROUTE, collections, blocks, status: "ready" };
|
|
257
|
+
}
|
|
258
|
+
// src/editor/PatternMapper.ts
|
|
259
|
+
function isRecord2(v) {
|
|
260
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
261
|
+
}
|
|
262
|
+
function rootProps(root) {
|
|
263
|
+
if (isRecord2(root.props))
|
|
264
|
+
return { ...root.props };
|
|
265
|
+
const { readOnly: _ro, ...flat } = root;
|
|
266
|
+
return { ...flat };
|
|
267
|
+
}
|
|
268
|
+
function nodeProps(props) {
|
|
269
|
+
const { id: _id, ...rest } = props;
|
|
270
|
+
return { ...rest };
|
|
271
|
+
}
|
|
272
|
+
function puckDataToDraft(puckData) {
|
|
273
|
+
const content = Array.isArray(puckData?.content) ? puckData.content : [];
|
|
274
|
+
const nodes = content.map((n, i) => ({
|
|
275
|
+
block: String(n.type),
|
|
276
|
+
id: typeof n.props?.id === "string" ? n.props.id : `node-${i}`,
|
|
277
|
+
props: nodeProps(n.props ?? {})
|
|
278
|
+
}));
|
|
279
|
+
return {
|
|
280
|
+
tree: {
|
|
281
|
+
root: { props: isRecord2(puckData?.root) ? rootProps(puckData.root) : {} },
|
|
282
|
+
nodes
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function draftToPuckData(draft, puckConfig) {
|
|
287
|
+
const known = puckConfig?.components ?? {};
|
|
288
|
+
const nodes = draft?.tree?.nodes ?? [];
|
|
289
|
+
const content = nodes.filter((n) => Object.prototype.hasOwnProperty.call(known, n.block)).map((n) => ({ type: n.block, props: { id: n.id, ...n.props } }));
|
|
290
|
+
return {
|
|
291
|
+
root: { props: { ...draft?.tree?.root?.props ?? {} } },
|
|
292
|
+
content
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
class EditorSaveError extends Error {
|
|
297
|
+
code;
|
|
298
|
+
status;
|
|
299
|
+
issues;
|
|
300
|
+
constructor(message, init) {
|
|
301
|
+
super(message);
|
|
302
|
+
this.name = "EditorSaveError";
|
|
303
|
+
this.code = init.code;
|
|
304
|
+
if (init.status !== undefined)
|
|
305
|
+
this.status = init.status;
|
|
306
|
+
if (init.issues !== undefined)
|
|
307
|
+
this.issues = init.issues;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function isApiErrorLike(e) {
|
|
311
|
+
return isRecord2(e) && typeof e.status === "number" && typeof e.code === "string" && typeof e.message === "string";
|
|
312
|
+
}
|
|
313
|
+
function toEditorSaveError(e) {
|
|
314
|
+
if (e instanceof EditorSaveError)
|
|
315
|
+
return e;
|
|
316
|
+
if (isApiErrorLike(e)) {
|
|
317
|
+
let code = "unknown";
|
|
318
|
+
if (e.status === 422)
|
|
319
|
+
code = "validation";
|
|
320
|
+
else if (e.status === 412)
|
|
321
|
+
code = "precondition-failed";
|
|
322
|
+
else if (e.status === 403)
|
|
323
|
+
code = "forbidden";
|
|
324
|
+
else if (e.status === 404)
|
|
325
|
+
code = "not-found";
|
|
326
|
+
return new EditorSaveError(e.message, {
|
|
327
|
+
code,
|
|
328
|
+
status: e.status,
|
|
329
|
+
issues: e.issues
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
return new EditorSaveError(e instanceof Error ? e.message : "Unexpected error while saving.", { code: "unknown" });
|
|
333
|
+
}
|
|
334
|
+
function puckOnPublishHandler(deps) {
|
|
335
|
+
return async (puckData) => {
|
|
336
|
+
const draft = puckDataToDraft(puckData);
|
|
337
|
+
try {
|
|
338
|
+
if (deps.id !== undefined) {
|
|
339
|
+
await deps.api.update(deps.collection, deps.id, draft, deps.etag);
|
|
340
|
+
return { id: deps.id };
|
|
341
|
+
}
|
|
342
|
+
const created = await deps.api.create(deps.collection, draft);
|
|
343
|
+
return { id: created.id };
|
|
344
|
+
} catch (e) {
|
|
345
|
+
throw toEditorSaveError(e);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function savePatternHandler(deps, selection) {
|
|
350
|
+
const name = selection?.name?.trim() ?? "";
|
|
351
|
+
const nodes = selection?.nodes ?? [];
|
|
352
|
+
if (name.length === 0 || nodes.length === 0) {
|
|
353
|
+
return Promise.reject(new EditorSaveError("Select at least one section and give the Pattern a name before saving.", { code: "empty-selection" }));
|
|
354
|
+
}
|
|
355
|
+
const { tree } = puckDataToDraft({
|
|
356
|
+
root: selection.root ?? {},
|
|
357
|
+
content: nodes
|
|
358
|
+
});
|
|
359
|
+
return deps.api.create("patterns", { name, tree }).then((res) => ({ id: res.id })).catch((e) => Promise.reject(toEditorSaveError(e)));
|
|
360
|
+
}
|
|
361
|
+
function issueFieldList(issues) {
|
|
362
|
+
if (!Array.isArray(issues))
|
|
363
|
+
return "";
|
|
364
|
+
const parts = [];
|
|
365
|
+
for (const it of issues) {
|
|
366
|
+
if (!isRecord2(it))
|
|
367
|
+
continue;
|
|
368
|
+
const path = Array.isArray(it.path) ? it.path.join(".") : typeof it.path === "string" ? it.path : "";
|
|
369
|
+
const msg = typeof it.message === "string" ? it.message : "is invalid";
|
|
370
|
+
parts.push(path.length > 0 ? `${path}: ${msg}` : msg);
|
|
371
|
+
}
|
|
372
|
+
return parts.join("; ");
|
|
373
|
+
}
|
|
374
|
+
function formatSaveError(err) {
|
|
375
|
+
switch (err.code) {
|
|
376
|
+
case "validation": {
|
|
377
|
+
const list = issueFieldList(err.issues);
|
|
378
|
+
return list.length > 0 ? `Couldn't save — please fix: ${list}.` : "Couldn't save — some fields are invalid. Please review and try again.";
|
|
379
|
+
}
|
|
380
|
+
case "precondition-failed":
|
|
381
|
+
return "Someone else changed this since you opened it. Reload to get the latest version, then re-apply your changes.";
|
|
382
|
+
case "empty-selection":
|
|
383
|
+
return err.message;
|
|
384
|
+
case "forbidden":
|
|
385
|
+
return "You don't have permission to make this change.";
|
|
386
|
+
case "not-found":
|
|
387
|
+
return "This item no longer exists — it may have been deleted.";
|
|
388
|
+
default:
|
|
389
|
+
return "Something went wrong while saving. Please try again.";
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// src/editor/nav.ts
|
|
393
|
+
var PATTERNS_ENTRY = {
|
|
394
|
+
kind: "patterns",
|
|
395
|
+
slug: "patterns",
|
|
396
|
+
label: "Patterns"
|
|
397
|
+
};
|
|
398
|
+
function buildNavModel(config) {
|
|
399
|
+
const { collections } = adminManifest(config);
|
|
400
|
+
const collectionEntries = collections.map((c) => ({
|
|
401
|
+
kind: "collection",
|
|
402
|
+
slug: c.slug,
|
|
403
|
+
label: c.label
|
|
404
|
+
}));
|
|
405
|
+
return { entries: [...collectionEntries, PATTERNS_ENTRY] };
|
|
406
|
+
}
|
|
407
|
+
// src/editor/SaacmsEditor.tsx
|
|
408
|
+
import { useMemo, useState } from "react";
|
|
409
|
+
import { Puck } from "@measured/puck";
|
|
410
|
+
import { jsxDEV, Fragment } from "react/jsx-dev-runtime";
|
|
411
|
+
var EMPTY_DATA = { root: { props: {} }, content: [], zones: {} };
|
|
412
|
+
function toPuckConfig(headless) {
|
|
413
|
+
const components = {};
|
|
414
|
+
for (const [name, comp] of Object.entries(headless.components)) {
|
|
415
|
+
components[name] = {
|
|
416
|
+
fields: comp.fields,
|
|
417
|
+
defaultProps: comp.defaultProps,
|
|
418
|
+
render: (props) => /* @__PURE__ */ jsxDEV("div", {
|
|
419
|
+
"data-saacms-block": name,
|
|
420
|
+
children: String(props.title ?? props.label ?? name)
|
|
421
|
+
}, undefined, false, undefined, this)
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return { components, root: { render: ({ children }) => /* @__PURE__ */ jsxDEV(Fragment, {
|
|
425
|
+
children
|
|
426
|
+
}, undefined, false, undefined, this) } };
|
|
427
|
+
}
|
|
428
|
+
function SaacmsEditor(props) {
|
|
429
|
+
const { config, collection, recordId, api, pageId, initialData } = props;
|
|
430
|
+
const puckConfig = useMemo(() => toPuckConfig(buildPuckConfig(config)), [config]);
|
|
431
|
+
const onPublish = useMemo(() => puckOnPublishHandler({ api, collection, id: recordId }), [api, collection, recordId]);
|
|
432
|
+
const [error, setError] = useState(null);
|
|
433
|
+
const [draftSaved, setDraftSaved] = useState(false);
|
|
434
|
+
const [savedPatternId, setSavedPatternId] = useState(null);
|
|
435
|
+
const [lastData, setLastData] = useState(initialData ?? EMPTY_DATA);
|
|
436
|
+
const [patternName, setPatternName] = useState("");
|
|
437
|
+
const handlePublish = (data) => {
|
|
438
|
+
setError(null);
|
|
439
|
+
onPublish(data).catch((e) => {
|
|
440
|
+
setError(e instanceof EditorSaveError ? e : null);
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
const handleSaveDraft = () => {
|
|
444
|
+
setError(null);
|
|
445
|
+
setDraftSaved(false);
|
|
446
|
+
if (pageId === undefined)
|
|
447
|
+
return;
|
|
448
|
+
const { tree } = puckDataToDraft(lastData);
|
|
449
|
+
api.saveDraft(pageId, tree).then(() => setDraftSaved(true)).catch((e) => {
|
|
450
|
+
setError(e instanceof EditorSaveError ? e : null);
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
const handleSavePattern = () => {
|
|
454
|
+
setError(null);
|
|
455
|
+
setSavedPatternId(null);
|
|
456
|
+
savePatternHandler({ api }, { name: patternName, nodes: lastData.content ?? [] }).then((res) => setSavedPatternId(res.id)).catch((e) => {
|
|
457
|
+
setError(e instanceof EditorSaveError ? e : null);
|
|
458
|
+
});
|
|
459
|
+
};
|
|
460
|
+
return /* @__PURE__ */ jsxDEV("div", {
|
|
461
|
+
"data-saacms-editor": true,
|
|
462
|
+
children: [
|
|
463
|
+
error !== null ? /* @__PURE__ */ jsxDEV("div", {
|
|
464
|
+
role: "alert",
|
|
465
|
+
"data-saacms-error-banner": true,
|
|
466
|
+
children: formatSaveError(error)
|
|
467
|
+
}, undefined, false, undefined, this) : null,
|
|
468
|
+
savedPatternId !== null ? /* @__PURE__ */ jsxDEV("div", {
|
|
469
|
+
role: "status",
|
|
470
|
+
"data-saacms-pattern-saved": true,
|
|
471
|
+
children: "Pattern saved."
|
|
472
|
+
}, undefined, false, undefined, this) : null,
|
|
473
|
+
draftSaved ? /* @__PURE__ */ jsxDEV("div", {
|
|
474
|
+
role: "status",
|
|
475
|
+
"data-saacms-draft-saved": true,
|
|
476
|
+
children: "Draft saved."
|
|
477
|
+
}, undefined, false, undefined, this) : null,
|
|
478
|
+
pageId !== undefined ? /* @__PURE__ */ jsxDEV("div", {
|
|
479
|
+
"data-saacms-draft-bar": true,
|
|
480
|
+
children: /* @__PURE__ */ jsxDEV("button", {
|
|
481
|
+
type: "button",
|
|
482
|
+
onClick: handleSaveDraft,
|
|
483
|
+
children: "Save draft"
|
|
484
|
+
}, undefined, false, undefined, this)
|
|
485
|
+
}, undefined, false, undefined, this) : null,
|
|
486
|
+
/* @__PURE__ */ jsxDEV("div", {
|
|
487
|
+
"data-saacms-pattern-bar": true,
|
|
488
|
+
children: [
|
|
489
|
+
/* @__PURE__ */ jsxDEV("input", {
|
|
490
|
+
"aria-label": "Pattern name",
|
|
491
|
+
value: patternName,
|
|
492
|
+
onChange: (e) => setPatternName(e.currentTarget.value),
|
|
493
|
+
placeholder: "Name this Pattern"
|
|
494
|
+
}, undefined, false, undefined, this),
|
|
495
|
+
/* @__PURE__ */ jsxDEV("button", {
|
|
496
|
+
type: "button",
|
|
497
|
+
onClick: handleSavePattern,
|
|
498
|
+
children: "Save selection as Pattern"
|
|
499
|
+
}, undefined, false, undefined, this)
|
|
500
|
+
]
|
|
501
|
+
}, undefined, true, undefined, this),
|
|
502
|
+
/* @__PURE__ */ jsxDEV(Puck, {
|
|
503
|
+
config: puckConfig,
|
|
504
|
+
data: lastData,
|
|
505
|
+
onChange: (d) => setLastData(d),
|
|
506
|
+
onPublish: handlePublish
|
|
507
|
+
}, undefined, false, undefined, this)
|
|
508
|
+
]
|
|
509
|
+
}, undefined, true, undefined, this);
|
|
510
|
+
}
|
|
511
|
+
// src/editor/AdminShell.tsx
|
|
512
|
+
import { useEffect, useMemo as useMemo2, useState as useState2 } from "react";
|
|
513
|
+
import { jsxDEV as jsxDEV2 } from "react/jsx-dev-runtime";
|
|
514
|
+
function CollectionList(props) {
|
|
515
|
+
const { api, collection, onEdit, onNew } = props;
|
|
516
|
+
const [rows, setRows] = useState2([]);
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
let live = true;
|
|
519
|
+
api.list(collection).then((r) => {
|
|
520
|
+
if (live)
|
|
521
|
+
setRows(r.data);
|
|
522
|
+
}).catch(() => {
|
|
523
|
+
if (live)
|
|
524
|
+
setRows([]);
|
|
525
|
+
});
|
|
526
|
+
return () => {
|
|
527
|
+
live = false;
|
|
528
|
+
};
|
|
529
|
+
}, [api, collection]);
|
|
530
|
+
return /* @__PURE__ */ jsxDEV2("div", {
|
|
531
|
+
"data-saacms-collection-list": true,
|
|
532
|
+
children: [
|
|
533
|
+
/* @__PURE__ */ jsxDEV2("button", {
|
|
534
|
+
type: "button",
|
|
535
|
+
onClick: onNew,
|
|
536
|
+
children: "New"
|
|
537
|
+
}, undefined, false, undefined, this),
|
|
538
|
+
/* @__PURE__ */ jsxDEV2("ul", {
|
|
539
|
+
children: rows.map((row, i) => {
|
|
540
|
+
const id = typeof row === "object" && row !== null && "id" in row ? String(row.id) : String(i);
|
|
541
|
+
return /* @__PURE__ */ jsxDEV2("li", {
|
|
542
|
+
children: /* @__PURE__ */ jsxDEV2("button", {
|
|
543
|
+
type: "button",
|
|
544
|
+
onClick: () => onEdit(id),
|
|
545
|
+
children: id
|
|
546
|
+
}, undefined, false, undefined, this)
|
|
547
|
+
}, id, false, undefined, this);
|
|
548
|
+
})
|
|
549
|
+
}, undefined, false, undefined, this)
|
|
550
|
+
]
|
|
551
|
+
}, undefined, true, undefined, this);
|
|
552
|
+
}
|
|
553
|
+
function AdminShell(props) {
|
|
554
|
+
const { config } = props;
|
|
555
|
+
const api = useMemo2(() => props.api ?? new AdminApiClient, [props.api]);
|
|
556
|
+
const nav = useMemo2(() => buildNavModel(config), [config]);
|
|
557
|
+
const [active, setActive] = useState2(nav.entries[0] ?? { kind: "patterns", slug: "patterns", label: "Patterns" });
|
|
558
|
+
const [editingId, setEditingId] = useState2(null);
|
|
559
|
+
const [creating, setCreating] = useState2(false);
|
|
560
|
+
const select = (entry) => {
|
|
561
|
+
setActive(entry);
|
|
562
|
+
setEditingId(null);
|
|
563
|
+
setCreating(false);
|
|
564
|
+
};
|
|
565
|
+
const inEditor = active.kind === "collection" && (editingId !== null || creating);
|
|
566
|
+
return /* @__PURE__ */ jsxDEV2("div", {
|
|
567
|
+
"data-saacms-admin-shell": true,
|
|
568
|
+
children: [
|
|
569
|
+
/* @__PURE__ */ jsxDEV2("nav", {
|
|
570
|
+
"aria-label": "Admin sections",
|
|
571
|
+
children: /* @__PURE__ */ jsxDEV2("ul", {
|
|
572
|
+
children: nav.entries.map((entry) => /* @__PURE__ */ jsxDEV2("li", {
|
|
573
|
+
children: /* @__PURE__ */ jsxDEV2("button", {
|
|
574
|
+
type: "button",
|
|
575
|
+
"aria-current": entry.slug === active.slug ? "page" : undefined,
|
|
576
|
+
onClick: () => select(entry),
|
|
577
|
+
children: entry.label
|
|
578
|
+
}, undefined, false, undefined, this)
|
|
579
|
+
}, entry.slug, false, undefined, this))
|
|
580
|
+
}, undefined, false, undefined, this)
|
|
581
|
+
}, undefined, false, undefined, this),
|
|
582
|
+
/* @__PURE__ */ jsxDEV2("main", {
|
|
583
|
+
children: active.kind === "patterns" ? /* @__PURE__ */ jsxDEV2("div", {
|
|
584
|
+
"data-saacms-patterns-view": true,
|
|
585
|
+
children: "Patterns are composed and saved from inside the editor."
|
|
586
|
+
}, undefined, false, undefined, this) : inEditor ? /* @__PURE__ */ jsxDEV2(SaacmsEditor, {
|
|
587
|
+
config,
|
|
588
|
+
collection: active.slug,
|
|
589
|
+
recordId: editingId ?? undefined,
|
|
590
|
+
api
|
|
591
|
+
}, undefined, false, undefined, this) : /* @__PURE__ */ jsxDEV2(CollectionList, {
|
|
592
|
+
api,
|
|
593
|
+
collection: active.slug,
|
|
594
|
+
onEdit: (id) => setEditingId(id),
|
|
595
|
+
onNew: () => setCreating(true)
|
|
596
|
+
}, undefined, false, undefined, this)
|
|
597
|
+
}, undefined, false, undefined, this)
|
|
598
|
+
]
|
|
599
|
+
}, undefined, true, undefined, this);
|
|
600
|
+
}
|
|
601
|
+
// src/mount.tsx
|
|
602
|
+
import { createElement } from "react";
|
|
603
|
+
import { createRoot } from "react-dom/client";
|
|
604
|
+
function mountAdmin(el, opts) {
|
|
605
|
+
const root = createRoot(el);
|
|
606
|
+
root.render(createElement(AdminShell, { config: opts.config, api: opts.api }));
|
|
607
|
+
return { unmount: () => root.unmount() };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/index.ts
|
|
611
|
+
var adminPlaceholderVersion = "0.1.0-placeholder";
|
|
612
|
+
function adminMountInfo() {
|
|
613
|
+
return { route: "/admin", status: "placeholder" };
|
|
614
|
+
}
|
|
615
|
+
export {
|
|
616
|
+
savePatternHandler,
|
|
617
|
+
puckOnPublishHandler,
|
|
618
|
+
puckDataToDraft,
|
|
619
|
+
pascalCase,
|
|
620
|
+
mountAdmin,
|
|
621
|
+
formatSaveError,
|
|
622
|
+
draftToPuckData,
|
|
623
|
+
buildPuckConfig,
|
|
624
|
+
buildNavModel,
|
|
625
|
+
adminPlaceholderVersion,
|
|
626
|
+
adminMountInfo,
|
|
627
|
+
adminManifest,
|
|
628
|
+
SaacmsEditor,
|
|
629
|
+
EditorSaveError,
|
|
630
|
+
AdminShell,
|
|
631
|
+
AdminApiError,
|
|
632
|
+
AdminApiClient,
|
|
633
|
+
ADMIN_ROUTE
|
|
634
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin route manifest — the static description the (future) React shell uses
|
|
3
|
+
* to render its collection/block navigation. Replaces the old
|
|
4
|
+
* `adminMountInfo()` placeholder; a back-compat `adminMountInfo()` that
|
|
5
|
+
* delegates to the same route constant is kept in the barrel so nothing
|
|
6
|
+
* importing it breaks.
|
|
7
|
+
*
|
|
8
|
+
* Collection `label` is `CollectionDef.label ?? PascalCase(slug)` (per ADR
|
|
9
|
+
* 0011 the admin mounts at `/admin/*`).
|
|
10
|
+
*/
|
|
11
|
+
import type { SaacmsConfig } from "@saacms/core";
|
|
12
|
+
/** Where the admin UI mounts in the host (ADR 0011). */
|
|
13
|
+
export declare const ADMIN_ROUTE: "/admin";
|
|
14
|
+
export interface AdminManifest {
|
|
15
|
+
readonly route: string;
|
|
16
|
+
readonly collections: ReadonlyArray<{
|
|
17
|
+
readonly slug: string;
|
|
18
|
+
readonly label: string;
|
|
19
|
+
}>;
|
|
20
|
+
readonly blocks: ReadonlyArray<{
|
|
21
|
+
readonly slug: string;
|
|
22
|
+
}>;
|
|
23
|
+
readonly status: "ready";
|
|
24
|
+
}
|
|
25
|
+
export declare function adminManifest(config: SaacmsConfig): AdminManifest;
|
|
26
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAGhD,wDAAwD;AACxD,eAAO,MAAM,WAAW,EAAG,QAAiB,CAAA;AAE5C,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC;QAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;QACrB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;KACvB,CAAC,CAAA;IACF,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACzD,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;CACzB;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,YAAY,GAAG,aAAa,CAOjE"}
|
package/dist/mount.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @saacms/admin v0.2 — host mount entry.
|
|
3
|
+
*
|
|
4
|
+
* The host's `/admin/*` route imports `mountAdmin` and calls it with a real
|
|
5
|
+
* DOM element. **SSR/import safety:** this module performs ZERO DOM access at
|
|
6
|
+
* import time — `createRoot` (and any `document`/`window` touch) happens only
|
|
7
|
+
* inside `mountAdmin`, so `await import("../mount.tsx")` resolves cleanly in
|
|
8
|
+
* Node with no DOM (proven by the editor test). Keep it that way.
|
|
9
|
+
*/
|
|
10
|
+
import type { SaacmsConfig } from "@saacms/core";
|
|
11
|
+
import { AdminApiClient } from "./api-client.ts";
|
|
12
|
+
export interface MountAdminOptions {
|
|
13
|
+
readonly config: SaacmsConfig;
|
|
14
|
+
/** Optional pre-built client; defaults to one on the conventional base path. */
|
|
15
|
+
readonly api?: AdminApiClient;
|
|
16
|
+
}
|
|
17
|
+
export interface AdminHandle {
|
|
18
|
+
/** Tear the React tree down (e.g. on host route unmount). */
|
|
19
|
+
readonly unmount: () => void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Render `<AdminShell>` into `el`. The only place in the package that touches
|
|
23
|
+
* `react-dom/client`'s `createRoot`; called exclusively from the host at
|
|
24
|
+
* runtime, never at module load.
|
|
25
|
+
*/
|
|
26
|
+
export declare function mountAdmin(el: HTMLElement, opts: MountAdminOptions): AdminHandle;
|
|
27
|
+
//# sourceMappingURL=mount.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mount.d.ts","sourceRoot":"","sources":["../src/mount.tsx"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAGhD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,gFAAgF;IAChF,QAAQ,CAAC,GAAG,CAAC,EAAE,cAAc,CAAA;CAC9B;AAED,MAAM,WAAW,WAAW;IAC1B,6DAA6D;IAC7D,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CACxB,EAAE,EAAE,WAAW,EACf,IAAI,EAAE,iBAAiB,GACtB,WAAW,CAMb"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildPuckConfig(config)` — project a `SaacmsConfig` into a Puck editor
|
|
3
|
+
* config (see `puck-types.ts` for why the Puck types are structural).
|
|
4
|
+
*
|
|
5
|
+
* One Puck `component` per registered Block (`config.blocks ?? []`), keyed by
|
|
6
|
+
* the block slug PascalCased (`rich-text` → `RichText`). Each component's
|
|
7
|
+
* `fields` is delegated to core's `schemaToPuckFields` — the single source of
|
|
8
|
+
* truth for the schema→field projection; we never re-derive field configs.
|
|
9
|
+
*
|
|
10
|
+
* `schemaToPuckFields` takes a `CollectionDef` (it only reads `.slug` +
|
|
11
|
+
* `.schema`). A `BlockDef` carries exactly those two members plus block-only
|
|
12
|
+
* fields the projector ignores, so the adapter is the minimal structural
|
|
13
|
+
* `{ slug, schema }` — documented inline at the call site.
|
|
14
|
+
*
|
|
15
|
+
* `defaultProps` is a *shallow, statically-knowable* zero-value map (v0.1):
|
|
16
|
+
* required top-level String → "", Number → 0, Boolean → false; optional
|
|
17
|
+
* properties are omitted (Puck treats every field as optional UI-wise and the
|
|
18
|
+
* Effect Schema enforces required-ness server-side). Literal unions, arrays
|
|
19
|
+
* and nested objects are omitted here — deep default extraction is v0.2.
|
|
20
|
+
*
|
|
21
|
+
* Component keys are emitted in sorted order so the generated config diffs
|
|
22
|
+
* cleanly across builds.
|
|
23
|
+
*/
|
|
24
|
+
import type { SaacmsConfig } from "@saacms/core";
|
|
25
|
+
import type { PuckConfig } from "./puck-types.ts";
|
|
26
|
+
/**
|
|
27
|
+
* `rich-text` → `RichText`. Splits on `-`, `_` and whitespace; capitalises the
|
|
28
|
+
* first letter of each token. Empty input yields `""`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function pascalCase(slug: string): string;
|
|
31
|
+
export declare function buildPuckConfig(config: SaacmsConfig): PuckConfig;
|
|
32
|
+
//# sourceMappingURL=puck-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"puck-config.d.ts","sourceRoot":"","sources":["../src/puck-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,KAAK,EAAE,YAAY,EAA2B,MAAM,cAAc,CAAA;AACzE,OAAO,KAAK,EAAE,UAAU,EAAuB,MAAM,iBAAiB,CAAA;AAEtE;;;GAGG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM/C;AAkFD,wBAAgB,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,UAAU,CAuBhE"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural slice of `@measured/puck`'s `Config` type.
|
|
3
|
+
*
|
|
4
|
+
* Per the codebase convention (KvNamespaceLike / BetterAuthLike / …): external
|
|
5
|
+
* libraries are typed *structurally*, never taken as a hard dependency. The
|
|
6
|
+
* admin emits a Puck `Config`-shaped object so the (future) React shell can
|
|
7
|
+
* hand it straight to `<Puck config={…}>`, but this v0.1 headless layer must
|
|
8
|
+
* stay React/Puck-free and tsc-clean — so we mirror only the members we emit.
|
|
9
|
+
*
|
|
10
|
+
* `fields` reuses core's `PuckField` union (the schema→field projector in
|
|
11
|
+
* `@saacms/core` is the single source of truth — we do not re-derive it).
|
|
12
|
+
*/
|
|
13
|
+
import type { PuckFields } from "@saacms/core";
|
|
14
|
+
export interface PuckComponentConfig {
|
|
15
|
+
readonly fields: PuckFields;
|
|
16
|
+
readonly defaultProps?: Readonly<Record<string, unknown>>;
|
|
17
|
+
}
|
|
18
|
+
export interface PuckConfig {
|
|
19
|
+
readonly components: Readonly<Record<string, PuckComponentConfig>>;
|
|
20
|
+
readonly root?: {
|
|
21
|
+
readonly fields?: PuckFields;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=puck-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"puck-types.d.ts","sourceRoot":"","sources":["../src/puck-types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAA;AAE9C,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;IAC3B,QAAQ,CAAC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CAC1D;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC,CAAA;IAClE,QAAQ,CAAC,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAA;KAAE,CAAA;CACjD"}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saacms/admin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"default": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc --build",
|
|
18
|
+
"typecheck": "tsc --build --noEmit",
|
|
19
|
+
"prepack": "cp package.json package.json.pack-bak && bun run ../../scripts/prepack-pkg.ts",
|
|
20
|
+
"postpack": "mv package.json.pack-bak package.json"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@saacms/core": "workspace:*",
|
|
27
|
+
"react": "^18.3.0",
|
|
28
|
+
"react-dom": "^18.3.0",
|
|
29
|
+
"@measured/puck": "^0.16.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bun": "latest",
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"@types/react": "^18.3.0",
|
|
35
|
+
"@types/react-dom": "^18.3.0"
|
|
36
|
+
},
|
|
37
|
+
"main": "./dist/index.js",
|
|
38
|
+
"types": "./dist/index.d.ts"
|
|
39
|
+
}
|