@nonext/husk 0.2.3

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 (89) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/LICENSE +166 -0
  3. package/README.md +140 -0
  4. package/api/public-api.md +1070 -0
  5. package/dist/adapter-C_0mhENy.d.ts +320 -0
  6. package/dist/admin/admin.css +5 -0
  7. package/dist/admin/index.d.ts +265 -0
  8. package/dist/admin/index.js +393 -0
  9. package/dist/admin/index.js.map +1 -0
  10. package/dist/admin/tokens.css +597 -0
  11. package/dist/admin/ui.d.ts +3013 -0
  12. package/dist/admin/ui.js +9783 -0
  13. package/dist/admin/ui.js.map +1 -0
  14. package/dist/auth/index.d.ts +4 -0
  15. package/dist/auth/index.js +9 -0
  16. package/dist/auth/index.js.map +1 -0
  17. package/dist/auth-B2gBtHl5.d.ts +28 -0
  18. package/dist/can-Chy7zmx7.d.ts +69 -0
  19. package/dist/chunk-3I6337WJ.js +83 -0
  20. package/dist/chunk-3I6337WJ.js.map +1 -0
  21. package/dist/chunk-4Z6P3XCE.js +62 -0
  22. package/dist/chunk-4Z6P3XCE.js.map +1 -0
  23. package/dist/chunk-6E3ZS5Z5.js +1007 -0
  24. package/dist/chunk-6E3ZS5Z5.js.map +1 -0
  25. package/dist/chunk-77JDRXXU.js +3 -0
  26. package/dist/chunk-77JDRXXU.js.map +1 -0
  27. package/dist/chunk-FHYWJCG6.js +171 -0
  28. package/dist/chunk-FHYWJCG6.js.map +1 -0
  29. package/dist/chunk-JAE7HHMM.js +506 -0
  30. package/dist/chunk-JAE7HHMM.js.map +1 -0
  31. package/dist/chunk-NPDBQBLW.js +68 -0
  32. package/dist/chunk-NPDBQBLW.js.map +1 -0
  33. package/dist/chunk-ORMYARXD.js +6 -0
  34. package/dist/chunk-ORMYARXD.js.map +1 -0
  35. package/dist/chunk-R4O7APIX.js +51 -0
  36. package/dist/chunk-R4O7APIX.js.map +1 -0
  37. package/dist/chunk-ROLKRJWI.js +187 -0
  38. package/dist/chunk-ROLKRJWI.js.map +1 -0
  39. package/dist/chunk-SOTQ3WP5.js +31 -0
  40. package/dist/chunk-SOTQ3WP5.js.map +1 -0
  41. package/dist/chunk-TCFFHTY3.js +97 -0
  42. package/dist/chunk-TCFFHTY3.js.map +1 -0
  43. package/dist/chunk-TS7Y7GEZ.js +7 -0
  44. package/dist/chunk-TS7Y7GEZ.js.map +1 -0
  45. package/dist/chunk-VLGR5PZX.js +3 -0
  46. package/dist/chunk-VLGR5PZX.js.map +1 -0
  47. package/dist/chunk-ZW2PUSVS.js +40 -0
  48. package/dist/chunk-ZW2PUSVS.js.map +1 -0
  49. package/dist/chunk-ZYNBMYJ6.js +392 -0
  50. package/dist/chunk-ZYNBMYJ6.js.map +1 -0
  51. package/dist/cli/nonext-husk.js +3360 -0
  52. package/dist/cli/nonext-husk.js.map +1 -0
  53. package/dist/cli/templates/firestore.rules +393 -0
  54. package/dist/cli/templates/storage.rules +165 -0
  55. package/dist/client/index.d.ts +27 -0
  56. package/dist/client/index.js +15 -0
  57. package/dist/client/index.js.map +1 -0
  58. package/dist/cms-9fSA3uQV.d.ts +158 -0
  59. package/dist/core/index.d.ts +1267 -0
  60. package/dist/core/index.js +12 -0
  61. package/dist/core/index.js.map +1 -0
  62. package/dist/entry-D3kiE8T1.d.ts +163 -0
  63. package/dist/field-Diyf5siF.d.ts +158 -0
  64. package/dist/field-type-D25Vojb0.d.ts +157 -0
  65. package/dist/firebase/index.d.ts +251 -0
  66. package/dist/firebase/index.js +991 -0
  67. package/dist/firebase/index.js.map +1 -0
  68. package/dist/index-5ylaMBUH.d.ts +155 -0
  69. package/dist/index.d.ts +14 -0
  70. package/dist/index.js +17 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/permissions/index.d.ts +101 -0
  73. package/dist/permissions/index.js +7 -0
  74. package/dist/permissions/index.js.map +1 -0
  75. package/dist/registry-CUC25qay.d.ts +59 -0
  76. package/dist/richtext/index.d.ts +85 -0
  77. package/dist/richtext/index.js +127 -0
  78. package/dist/richtext/index.js.map +1 -0
  79. package/dist/schema/index.d.ts +50 -0
  80. package/dist/schema/index.js +5 -0
  81. package/dist/schema/index.js.map +1 -0
  82. package/dist/server/index.d.ts +39 -0
  83. package/dist/server/index.js +25 -0
  84. package/dist/server/index.js.map +1 -0
  85. package/dist/types/index.d.ts +36 -0
  86. package/dist/types/index.js +6 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/write-limits-Djmm6L3R.d.ts +215 -0
  89. package/package.json +136 -0
@@ -0,0 +1,4 @@
1
+ export { A as AuthError, a as AuthErrorCode, C as ClaimSet, b as ClaimSync, c as ClaimSyncError, E as EmulatorClaimSyncOptions, H as HuskAuth, d as HuskAuthDeps, N as NewUser, S as SignUpInput, e as StoredUser, U as UserPatch, f as createEmulatorClaimSync, g as createHuskAuth, h as createUnavailableClaimSync, t as toAuthUser } from '../index-5ylaMBUH.js';
2
+ export { b as AuthUser, R as ROLES, e as Role } from '../auth-B2gBtHl5.js';
3
+ import 'firebase/auth';
4
+ import 'firebase/firestore';
@@ -0,0 +1,9 @@
1
+ export { AuthError, ClaimSyncError, createEmulatorClaimSync, createHuskAuth, createUnavailableClaimSync, toAuthUser } from '../chunk-ZYNBMYJ6.js';
2
+ import '../chunk-ROLKRJWI.js';
3
+ import '../chunk-SOTQ3WP5.js';
4
+ import '../chunk-TCFFHTY3.js';
5
+ import '../chunk-NPDBQBLW.js';
6
+ export { ROLES } from '../chunk-TS7Y7GEZ.js';
7
+ import '../chunk-4Z6P3XCE.js';
8
+ //# sourceMappingURL=index.js.map
9
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -0,0 +1,28 @@
1
+ declare const ROLES: readonly ["admin", "editor", "author"];
2
+ type Role = (typeof ROLES)[number];
3
+ /** Actions performed on the entries of one content type. */
4
+ type EntryAction = "read" | "create" | "update" | "delete";
5
+ /** Actions that are not scoped to a content type. */
6
+ type AdminAction = "manage-schema" | "manage-users" | "manage-settings" | "manage-media";
7
+ type Action = EntryAction | AdminAction;
8
+ declare const ENTRY_ACTIONS: readonly ["read", "create", "update", "delete"];
9
+ /**
10
+ * Who may do what with one content type. A missing action falls back to the role defaults
11
+ * in `can()`, so an empty matrix means "use the defaults", not "deny everything".
12
+ */
13
+ type PermissionMatrix = Partial<Record<EntryAction, readonly Role[]>>;
14
+ /** One resolved permission question, as the UI, the adapter, and the tests all phrase it. */
15
+ interface Permission {
16
+ action: Action;
17
+ /** Content type slug. Absent for an `AdminAction`. */
18
+ contentType?: string;
19
+ }
20
+ interface AuthUser {
21
+ uid: string;
22
+ email: string;
23
+ displayName: string | null;
24
+ role: Role;
25
+ disabled: boolean;
26
+ }
27
+
28
+ export { type Action as A, ENTRY_ACTIONS as E, type Permission as P, ROLES as R, type AdminAction as a, type AuthUser as b, type EntryAction as c, type PermissionMatrix as d, type Role as e };
@@ -0,0 +1,69 @@
1
+ import { A as Action, e as Role, b as AuthUser, c as EntryAction } from './auth-B2gBtHl5.js';
2
+ import { c as ContentTypeLike, h as EntryMeta } from './entry-D3kiE8T1.js';
3
+
4
+ /**
5
+ * The one permission resolver.
6
+ *
7
+ * The admin UI calls it to hide what a user may not do, the adapter calls it again to
8
+ * refuse the call when the UI is bypassed, and the security rules encode the same matrix a
9
+ * third time. Three layers, one truth table. Keep this pure and synchronous: it has to run
10
+ * inside a render, inside a write path, and inside a test, with no Firebase and no IO.
11
+ */
12
+ declare const ADMIN_ACTIONS: readonly ["manage-schema", "manage-users", "manage-settings", "manage-media"];
13
+ /** Every action the resolver answers for. The test cross-checks it against the table. */
14
+ declare const ACTIONS: readonly Action[];
15
+ /**
16
+ * An allow that still depends on who created the entry. Kept as a third state rather than
17
+ * resolved to a boolean early, so an ownership-gated action can be told apart from a plain
18
+ * denial and answered against the entry the caller passed in.
19
+ */
20
+ type Decision = "allow" | "deny" | "own-entry-only";
21
+ /**
22
+ * The role defaults, exhaustive by construction: `Record<Role, Record<Action, Decision>>`
23
+ * fails to typecheck the moment a role or an action is added without a decision here.
24
+ */
25
+ declare const PERMISSION_DEFAULTS: Readonly<Record<Role, Readonly<Record<Action, Decision>>>>;
26
+ /** Just enough of an entry to answer an ownership question. */
27
+ type EntryOwnership = Pick<EntryMeta, "createdBy">;
28
+ interface PermissionContext {
29
+ /** The content type the action targets. Carries the per-type permission matrix, if any. */
30
+ type?: ContentTypeLike;
31
+ /**
32
+ * The entry the action targets. Absent means the caller does not know which entry, which
33
+ * denies an ownership-gated action rather than guessing in the user's favour.
34
+ */
35
+ entry?: EntryOwnership;
36
+ }
37
+ declare function isEntryAction(action: Action): action is EntryAction;
38
+ /**
39
+ * Answers one permission question.
40
+ *
41
+ * Ownership is resolved against `context.entry`, not against the content type, because an
42
+ * author's `update` is a question about one entry and cannot be answered honestly without
43
+ * it. A caller that does not pass the entry gets `false`: "I cannot tell" has to read as a
44
+ * denial at the UI layer, since the adapter and the rules will deny it too.
45
+ *
46
+ * `context.type.permissions` narrows the role defaults for entry actions on that type. An
47
+ * absent matrix, and an action absent from it, mean "use the defaults", never "deny
48
+ * everything", so adding a matrix for one action does not silently lock out the rest.
49
+ *
50
+ * A matrix narrows and never widens. Three things follow, and M10 settled all three by
51
+ * making the security rules agree with them rather than by softening them here. It cannot
52
+ * restrict an admin. It cannot lift an author's ownership rule, so ticking `author` under
53
+ * `update` grants that action on the author's own entries only. And it cannot grant an
54
+ * action the role's own default denies, so ticking `author` under `delete` changes nothing:
55
+ * firestore.rules resolves the role defaults from the token alone and only consults the
56
+ * matrix to narrow further, because a rule that widened a privilege from a stored document
57
+ * would be trusting the document to grant it. See permissions/matrix.ts.
58
+ */
59
+ declare function can(user: AuthUser, action: Action, context?: PermissionContext): boolean;
60
+ /**
61
+ * The truth table lookup on its own, without the entry.
62
+ *
63
+ * Exported because `permissions/matrix.ts` labels the Developer area's table with it and a
64
+ * second implementation of the same lookup would be a second place for the three layers to
65
+ * drift apart. `can()` is this plus the ownership question.
66
+ */
67
+ declare function resolveDecision(role: Role, action: Action, type: Pick<ContentTypeLike, "permissions"> | undefined): Decision;
68
+
69
+ export { ACTIONS as A, type Decision as D, type EntryOwnership as E, PERMISSION_DEFAULTS as P, ADMIN_ACTIONS as a, type PermissionContext as b, can as c, isEntryAction as i, resolveDecision as r };
@@ -0,0 +1,83 @@
1
+ import { assertListable, isSingleton } from './chunk-R4O7APIX.js';
2
+ import { SingletonError, SchemaError } from './chunk-4Z6P3XCE.js';
3
+
4
+ // src/core/cms.ts
5
+ function statusFor(preview) {
6
+ return preview ? "any" : "published";
7
+ }
8
+ function visible(entry, preview) {
9
+ if (entry === null) return null;
10
+ if (preview) return entry;
11
+ return entry.status === "published" ? entry : null;
12
+ }
13
+ function assertMapKeysMatch(contentTypes) {
14
+ for (const [key, type] of Object.entries(contentTypes)) {
15
+ if (type.slug !== key) {
16
+ throw new SchemaError(
17
+ `Content type map key "${key}" holds the content type "${type.slug}". The key is what a read is looked up by, so a mismatch would type one content type and read another.`
18
+ );
19
+ }
20
+ }
21
+ }
22
+ function createCMS(config) {
23
+ const { adapter } = config;
24
+ const contentTypes = config.contentTypes ?? {};
25
+ const preview = config.preview ?? false;
26
+ const status = statusFor(preview);
27
+ assertMapKeysMatch(contentTypes);
28
+ function known(slug) {
29
+ return contentTypes[slug];
30
+ }
31
+ function collection(slug) {
32
+ const type = known(slug);
33
+ if (type) assertListable(type);
34
+ const reader = {
35
+ async findMany(query = {}) {
36
+ return adapter.list(slug, { ...query, status });
37
+ },
38
+ async findBySlug(value) {
39
+ return visible(await adapter.getBySlug(slug, value, { status }), preview);
40
+ },
41
+ async findById(entryId) {
42
+ return visible(await adapter.get(slug, entryId), preview);
43
+ },
44
+ async count(query = {}) {
45
+ return adapter.count(slug, { ...query, status });
46
+ }
47
+ };
48
+ return reader;
49
+ }
50
+ function single(slug) {
51
+ const type = known(slug);
52
+ if (type && !isSingleton(type)) {
53
+ throw new SingletonError(
54
+ type.slug,
55
+ `Content type "${type.slug}" is a collection. Read it with collection("${type.slug}").findMany().`
56
+ );
57
+ }
58
+ const reader = {
59
+ async get() {
60
+ return visible(await adapter.getSingle(slug), preview);
61
+ }
62
+ };
63
+ return reader;
64
+ }
65
+ const media = {
66
+ getById: (mediaId) => adapter.media.getById(mediaId),
67
+ getMany: (mediaIds) => adapter.media.getMany(mediaIds)
68
+ };
69
+ const settings = {
70
+ get: (key) => adapter.settings.get(key)
71
+ };
72
+ return {
73
+ collection,
74
+ single,
75
+ media: () => media,
76
+ settings: () => settings,
77
+ isPreview: preview
78
+ };
79
+ }
80
+
81
+ export { createCMS };
82
+ //# sourceMappingURL=chunk-3I6337WJ.js.map
83
+ //# sourceMappingURL=chunk-3I6337WJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/cms.ts"],"names":[],"mappings":"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","file":"chunk-3I6337WJ.js","sourcesContent":["import type { AnyContentTypeShape, Entry } from \"../types/entry.js\"\nimport type { MediaItem } from \"../types/media.js\"\n\nimport type { CmsAdapter, ListQuery, Page } from \"./adapter.js\"\nimport { assertListable, isSingleton } from \"./content-kind.js\"\nimport type { KindShape } from \"./content-kind.js\"\nimport { SchemaError, SingletonError } from \"./errors.js\"\n\n/**\n * The public read SDK. The surface a customer project programs against.\n *\n * Everything here goes through `CmsAdapter`, which is the whole point: the concept document\n * says the SDK will probably outlive the admin, and that a change to the internal Firestore\n * layout should cost a rewrite of the adapter rather than a rewrite of every customer\n * project. Nothing in this file names a collection, a document id, or a field path in\n * storage, so there is no layout knowledge here to invalidate.\n *\n * It is read-only on purpose. Writing is the admin's job, and it goes through the adapter\n * with a signed-in user, permission checks, and validation. Adding a `create` here would\n * mean a second write path with none of that.\n */\n\n/**\n * The code-defined content types an instance knows at compile time, keyed by slug.\n *\n * Keyed rather than a list, because that is what makes `cms.collection(\"projects\")` return\n * a typed entry. `ContentType<TFields>` declares `slug: string`, not the literal, so a list\n * of content types offers the type system nothing to index by. The key is the index.\n *\n * The key must equal the type's own `slug`; `createCMS` rejects a map where it does not,\n * because a mismatch would type one content type and read another.\n */\nexport type ContentTypeMap = { readonly [slug: string]: KindShape & AnyContentTypeShape }\n\n/** The slugs a map declares. */\nexport type KnownSlug<TTypes extends ContentTypeMap> = keyof TTypes & string\n\n/**\n * A known slug, or any other string.\n *\n * Both halves are required. The known slugs are what an editor offers and what carries the\n * inference; the open half is what keeps a database-defined content type readable, since\n * one created in the Schema Builder has no compile-time existence at all.\n */\nexport type SlugOf<TTypes extends ContentTypeMap> =\n KnownSlug<TTypes> | (string & Record<never, never>)\n\n/**\n * What one slug reads as.\n *\n * A declared slug resolves to its inferred entry, so `project.title` is `string`. Anything\n * else resolves to the erased `Entry`, whose field values are the union of every field\n * type's value, nullable. That is deliberately not a lie: a database-defined type has no\n * shape at compile time, so the honest type is \"some field value or null\", and a consumer\n * has to narrow before using it. Typing it as `any`, or pretending it has the fields of\n * some other type, would make the compiler agree to code that cannot work.\n */\nexport type EntryOf<\n TTypes extends ContentTypeMap,\n TSlug extends string,\n> = TSlug extends keyof TTypes ? Entry<TTypes[TSlug]> : Entry\n\n/**\n * What a public read may narrow by.\n *\n * `ListQuery.status` is removed rather than defaulted. A caller on a customer's website\n * cannot ask for drafts at all, which means a leak cannot come from a forwarded query\n * object, a URL parameter that ends up in a filter, or a copied snippet. Status is decided\n * once, by whether the instance is a preview instance.\n */\nexport type CmsQuery = Omit<ListQuery, \"status\">\n\n/** The narrowing a count understands. The rest of a query does not change a total. */\nexport type CmsCountQuery = Pick<CmsQuery, \"filters\" | \"search\">\n\nexport interface CollectionReader<TEntry = Entry> {\n /** One page of entries. Pass `page.cursor` back as `query.cursor` for the next one. */\n findMany(query?: CmsQuery): Promise<Page<TEntry>>\n /** By the value of the content type's slug field, which is what a URL segment carries. */\n findBySlug(value: string): Promise<TEntry | null>\n /** By entry id. */\n findById(entryId: string): Promise<TEntry | null>\n /** How many entries the same narrowing would return. */\n count(query?: CmsCountQuery): Promise<number>\n}\n\nexport interface SingleReader<TEntry = Entry> {\n /** The one entry of a singleton. Null when it has never been saved. */\n get(): Promise<TEntry | null>\n}\n\nexport interface MediaReader {\n getById(mediaId: string): Promise<MediaItem | null>\n /** Missing ids are omitted rather than returned as holes, so the result may be shorter. */\n getMany(mediaIds: readonly string[]): Promise<readonly MediaItem[]>\n}\n\nexport interface SettingsReader {\n /** A settings document by key, or null when it has never been written. */\n get(key: string): Promise<Record<string, unknown> | null>\n}\n\nexport interface Cms<TTypes extends ContentTypeMap = ContentTypeMap> {\n collection<TSlug extends SlugOf<TTypes>>(\n slug: TSlug,\n ): CollectionReader<EntryOf<TTypes, TSlug>>\n single<TSlug extends SlugOf<TTypes>>(slug: TSlug): SingleReader<EntryOf<TTypes, TSlug>>\n media(): MediaReader\n settings(): SettingsReader\n /** True when this instance reads unpublished entries. False on every public instance. */\n readonly isPreview: boolean\n}\n\n/**\n * Everything an instance needs except the decision about unpublished content.\n *\n * Split out so the client entry can accept exactly this and nothing more: a config type\n * with no `preview` key makes passing one a compile error rather than a review comment.\n */\nexport interface CmsReadConfig<TTypes extends ContentTypeMap = ContentTypeMap> {\n /** The storage seam. The SDK holds no other handle on data. */\n adapter: CmsAdapter\n /**\n * The code-defined content types, keyed by slug. Omitting them costs the inference and\n * nothing else: every read still works and every entry is typed as the erased `Entry`.\n */\n contentTypes?: TTypes\n}\n\nexport interface CmsConfig<\n TTypes extends ContentTypeMap = ContentTypeMap,\n> extends CmsReadConfig<TTypes> {\n /**\n * Reads every status instead of published only.\n *\n * Off unless it is passed, and there is no way to turn it on after construction: a\n * preview instance is a different object built by a different call. See\n * `.docs/decisions/2026-09-19-cms-sdk-surface.md` for why it is not a per-call argument.\n */\n preview?: boolean\n}\n\n/**\n * What a read asks the adapter for.\n *\n * `\"published\"` is the whole safety property: a customer who writes\n * `cms.collection(\"posts\").findMany()` on their homepage gets published entries without\n * having known to ask. `\"any\"` is the preview instance, which reads drafts and archived\n * entries as well, because the adapter offers one status or all of them and a preview that\n * hid archived content would be lying about what is stored.\n */\nfunction statusFor(preview: boolean): \"published\" | \"any\" {\n return preview ? \"any\" : \"published\"\n}\n\n/**\n * Applies the same narrowing to a single-document read.\n *\n * The list path narrows in the query, because a query has a where clause. A read by id or\n * by slug field returns one document and there is nothing to narrow in, so the check\n * happens here. Missing it would be the exact leak the default exists to prevent: a draft\n * is invisible in the index but reachable by guessing its URL.\n */\nfunction visible(entry: Entry | null, preview: boolean): Entry | null {\n if (entry === null) return null\n if (preview) return entry\n return entry.status === \"published\" ? entry : null\n}\n\nfunction assertMapKeysMatch(contentTypes: ContentTypeMap): void {\n for (const [key, type] of Object.entries(contentTypes)) {\n if (type.slug !== key) {\n throw new SchemaError(\n `Content type map key \"${key}\" holds the content type \"${type.slug}\". The key is what a read is looked up by, so a mismatch would type one content type and read another.`,\n )\n }\n }\n}\n\n/**\n * Builds a CMS instance over an adapter.\n *\n * Server and client variants live in `@nonext/husk/server` and `@nonext/husk/client`; this is\n * the shared implementation both are built from, and it is exported so a test or a custom\n * runtime can construct one directly over a test double.\n */\nexport function createCMS<TTypes extends ContentTypeMap = ContentTypeMap>(\n config: CmsConfig<TTypes>,\n): Cms<TTypes> {\n const { adapter } = config\n const contentTypes: ContentTypeMap = config.contentTypes ?? {}\n const preview = config.preview ?? false\n const status = statusFor(preview)\n\n assertMapKeysMatch(contentTypes)\n\n /**\n * The compile-time definition of a slug, when there is one.\n *\n * A database-defined type is absent here by construction, which is why the kind guards\n * below only fire for a known type. The adapter guards the rest: it holds the live\n * registry and knows the kind of every type, code-defined or not.\n */\n function known(slug: string): (KindShape & AnyContentTypeShape) | undefined {\n return contentTypes[slug]\n }\n\n function collection<TSlug extends SlugOf<TTypes>>(\n slug: TSlug,\n ): CollectionReader<EntryOf<TTypes, TSlug>> {\n const type = known(slug)\n // Fails at the call rather than inside the first read, so the message names the verb\n // that was wrong instead of a query that could not be built.\n if (type) assertListable(type)\n\n const reader: CollectionReader<Entry> = {\n async findMany(query: CmsQuery = {}) {\n return adapter.list(slug, { ...query, status })\n },\n async findBySlug(value: string) {\n // The status goes into the lookup as well as through `visible` below. The second\n // check is what makes a draft unreachable by guessing its URL; the first is what\n // makes the query legal for an unauthenticated visitor, whose security rules only\n // allow a query that says which status it wants.\n return visible(await adapter.getBySlug(slug, value, { status }), preview)\n },\n async findById(entryId: string) {\n return visible(await adapter.get(slug, entryId), preview)\n },\n async count(query: CmsCountQuery = {}) {\n return adapter.count(slug, { ...query, status })\n },\n }\n\n return reader as CollectionReader<EntryOf<TTypes, TSlug>>\n }\n\n function single<TSlug extends SlugOf<TTypes>>(\n slug: TSlug,\n ): SingleReader<EntryOf<TTypes, TSlug>> {\n const type = known(slug)\n if (type && !isSingleton(type)) {\n throw new SingletonError(\n type.slug,\n `Content type \"${type.slug}\" is a collection. Read it with collection(\"${type.slug}\").findMany().`,\n )\n }\n\n const reader: SingleReader<Entry> = {\n async get() {\n return visible(await adapter.getSingle(slug), preview)\n },\n }\n\n return reader as SingleReader<EntryOf<TTypes, TSlug>>\n }\n\n const media: MediaReader = {\n getById: (mediaId) => adapter.media.getById(mediaId),\n getMany: (mediaIds) => adapter.media.getMany(mediaIds),\n }\n\n const settings: SettingsReader = {\n get: (key) => adapter.settings.get(key),\n }\n\n return {\n collection,\n single,\n media: () => media,\n settings: () => settings,\n isPreview: preview,\n }\n}\n"]}
@@ -0,0 +1,62 @@
1
+ // src/core/errors.ts
2
+ var SchemaError = class extends Error {
3
+ name = "SchemaError";
4
+ constructor(message) {
5
+ super(message);
6
+ }
7
+ };
8
+ var ValidationError = class extends Error {
9
+ name = "ValidationError";
10
+ errors;
11
+ constructor(errors) {
12
+ super(
13
+ errors.length === 1 && errors[0] ? `${errors[0].path}: ${errors[0].message}` : `${errors.length} fields failed validation`
14
+ );
15
+ this.errors = errors;
16
+ }
17
+ };
18
+ var LockedTypeError = class extends Error {
19
+ name = "LockedTypeError";
20
+ slug;
21
+ constructor(slug, reason) {
22
+ super(
23
+ reason ?? `Content type "${slug}" is locked. It is defined in code and cannot be changed through the Schema Builder or the adapter.`
24
+ );
25
+ this.slug = slug;
26
+ }
27
+ };
28
+ var SingletonError = class extends Error {
29
+ name = "SingletonError";
30
+ slug;
31
+ constructor(slug, message) {
32
+ super(message);
33
+ this.slug = slug;
34
+ }
35
+ };
36
+ var PermissionError = class extends Error {
37
+ name = "PermissionError";
38
+ action;
39
+ /** Content type slug. Absent for an action that is not scoped to one. */
40
+ contentType;
41
+ constructor(action, contentType, message) {
42
+ super(
43
+ message ?? (contentType === void 0 ? `This account may not ${action}.` : `This account may not ${action} entries of "${contentType}".`)
44
+ );
45
+ this.action = action;
46
+ this.contentType = contentType;
47
+ }
48
+ };
49
+ var WriteLimitError = class extends Error {
50
+ name = "WriteLimitError";
51
+ code;
52
+ path;
53
+ constructor(rejection) {
54
+ super(rejection.message);
55
+ this.code = rejection.code;
56
+ this.path = rejection.path;
57
+ }
58
+ };
59
+
60
+ export { LockedTypeError, PermissionError, SchemaError, SingletonError, ValidationError, WriteLimitError };
61
+ //# sourceMappingURL=chunk-4Z6P3XCE.js.map
62
+ //# sourceMappingURL=chunk-4Z6P3XCE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/errors.ts"],"names":[],"mappings":";AAOO,IAAM,WAAA,GAAN,cAA0B,KAAA,CAAM;AAAA,EACnB,IAAA,GAAO,aAAA;AAAA,EAEzB,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AAAA,EACf;AACF;AAGO,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA,EACvB,IAAA,GAAO,iBAAA;AAAA,EAChB,MAAA;AAAA,EAET,YAAY,MAAA,EAA+B;AACzC,IAAA,KAAA;AAAA,MACE,OAAO,MAAA,KAAW,CAAA,IAAK,OAAO,CAAC,CAAA,GAC3B,GAAG,MAAA,CAAO,CAAC,EAAE,IAAI,CAAA,EAAA,EAAK,OAAO,CAAC,CAAA,CAAE,OAAO,CAAA,CAAA,GACvC,CAAA,EAAG,OAAO,MAAM,CAAA,yBAAA;AAAA,KACtB;AACA,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AAAA,EAChB;AACF;AAGO,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA,EACvB,IAAA,GAAO,iBAAA;AAAA,EAChB,IAAA;AAAA,EAET,WAAA,CAAY,MAAc,MAAA,EAAiB;AACzC,IAAA,KAAA;AAAA,MACE,MAAA,IACE,iBAAiB,IAAI,CAAA,mGAAA;AAAA,KACzB;AACA,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AAAA,EACd;AACF;AAGO,IAAM,cAAA,GAAN,cAA6B,KAAA,CAAM;AAAA,EACtB,IAAA,GAAO,gBAAA;AAAA,EAChB,IAAA;AAAA,EAET,WAAA,CAAY,MAAc,OAAA,EAAiB;AACzC,IAAA,KAAA,CAAM,OAAO,CAAA;AACb,IAAA,IAAA,CAAK,IAAA,GAAO,IAAA;AAAA,EACd;AACF;AAUO,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA,EACvB,IAAA,GAAO,iBAAA;AAAA,EAChB,MAAA;AAAA;AAAA,EAEA,WAAA;AAAA,EAET,WAAA,CAAY,MAAA,EAAgB,WAAA,EAAsB,OAAA,EAAkB;AAClE,IAAA,KAAA;AAAA,MACE,OAAA,KACG,gBAAgB,MAAA,GACb,CAAA,qBAAA,EAAwB,MAAM,CAAA,CAAA,CAAA,GAC9B,CAAA,qBAAA,EAAwB,MAAM,CAAA,aAAA,EAAgB,WAAW,CAAA,EAAA,CAAA;AAAA,KACjE;AACA,IAAA,IAAA,CAAK,MAAA,GAAS,MAAA;AACd,IAAA,IAAA,CAAK,WAAA,GAAc,WAAA;AAAA,EACrB;AACF;AAOO,IAAM,eAAA,GAAN,cAA8B,KAAA,CAAM;AAAA,EACvB,IAAA,GAAO,iBAAA;AAAA,EAChB,IAAA;AAAA,EACA,IAAA;AAAA,EAET,YAAY,SAAA,EAA4D;AACtE,IAAA,KAAA,CAAM,UAAU,OAAO,CAAA;AACvB,IAAA,IAAA,CAAK,OAAO,SAAA,CAAU,IAAA;AACtB,IAAA,IAAA,CAAK,OAAO,SAAA,CAAU,IAAA;AAAA,EACxB;AACF","file":"chunk-4Z6P3XCE.js","sourcesContent":["import type { FieldError } from \"../types/entry.js\"\n\n/**\n * A content type, field map, or registry is malformed. Thrown at definition time, which\n * for a code-defined type means at module load, so the mistake surfaces before any data\n * is written.\n */\nexport class SchemaError extends Error {\n override readonly name = \"SchemaError\"\n\n constructor(message: string) {\n super(message)\n }\n}\n\n/** Entry data failed the compiled validator. Carries one error per offending field path. */\nexport class ValidationError extends Error {\n override readonly name = \"ValidationError\"\n readonly errors: readonly FieldError[]\n\n constructor(errors: readonly FieldError[]) {\n super(\n errors.length === 1 && errors[0]\n ? `${errors[0].path}: ${errors[0].message}`\n : `${errors.length} fields failed validation`,\n )\n this.errors = errors\n }\n}\n\n/** A schema write was attempted against a content type that does not accept one. */\nexport class LockedTypeError extends Error {\n override readonly name = \"LockedTypeError\"\n readonly slug: string\n\n constructor(slug: string, reason?: string) {\n super(\n reason ??\n `Content type \"${slug}\" is locked. It is defined in code and cannot be changed through the Schema Builder or the adapter.`,\n )\n this.slug = slug\n }\n}\n\n/** A singleton was asked to hold more than the one entry it is allowed. */\nexport class SingletonError extends Error {\n override readonly name = \"SingletonError\"\n readonly slug: string\n\n constructor(slug: string, message: string) {\n super(message)\n this.slug = slug\n }\n}\n\n/**\n * The caller is not allowed to do this. Thrown by the adapter, from the same `can()` the\n * UI asked before it drew the button and the same matrix firestore.rules enforces.\n *\n * It exists as its own class because \"you may not\" and \"that is malformed\" need different\n * answers: a permission refusal is never fixed by editing the value. Carrying the action\n * and the content type lets a caller say which one without parsing the message.\n */\nexport class PermissionError extends Error {\n override readonly name = \"PermissionError\"\n readonly action: string\n /** Content type slug. Absent for an action that is not scoped to one. */\n readonly contentType: string | undefined\n\n constructor(action: string, contentType?: string, message?: string) {\n super(\n message ??\n (contentType === undefined\n ? `This account may not ${action}.`\n : `This account may not ${action} entries of \"${contentType}\".`),\n )\n this.action = action\n this.contentType = contentType\n }\n}\n\n/**\n * A write was refused for its size, its shape, or its timing rather than its content.\n * See core/write-limits.ts for which of these the security rules enforce as well and\n * which are the adapter's alone.\n */\nexport class WriteLimitError extends Error {\n override readonly name = \"WriteLimitError\"\n readonly code: string\n readonly path: string\n\n constructor(rejection: { code: string; path: string; message: string }) {\n super(rejection.message)\n this.code = rejection.code\n this.path = rejection.path\n }\n}\n"]}