@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.
- package/CHANGELOG.md +148 -0
- package/LICENSE +166 -0
- package/README.md +140 -0
- package/api/public-api.md +1070 -0
- package/dist/adapter-C_0mhENy.d.ts +320 -0
- package/dist/admin/admin.css +5 -0
- package/dist/admin/index.d.ts +265 -0
- package/dist/admin/index.js +393 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/tokens.css +597 -0
- package/dist/admin/ui.d.ts +3013 -0
- package/dist/admin/ui.js +9783 -0
- package/dist/admin/ui.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.js +9 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth-B2gBtHl5.d.ts +28 -0
- package/dist/can-Chy7zmx7.d.ts +69 -0
- package/dist/chunk-3I6337WJ.js +83 -0
- package/dist/chunk-3I6337WJ.js.map +1 -0
- package/dist/chunk-4Z6P3XCE.js +62 -0
- package/dist/chunk-4Z6P3XCE.js.map +1 -0
- package/dist/chunk-6E3ZS5Z5.js +1007 -0
- package/dist/chunk-6E3ZS5Z5.js.map +1 -0
- package/dist/chunk-77JDRXXU.js +3 -0
- package/dist/chunk-77JDRXXU.js.map +1 -0
- package/dist/chunk-FHYWJCG6.js +171 -0
- package/dist/chunk-FHYWJCG6.js.map +1 -0
- package/dist/chunk-JAE7HHMM.js +506 -0
- package/dist/chunk-JAE7HHMM.js.map +1 -0
- package/dist/chunk-NPDBQBLW.js +68 -0
- package/dist/chunk-NPDBQBLW.js.map +1 -0
- package/dist/chunk-ORMYARXD.js +6 -0
- package/dist/chunk-ORMYARXD.js.map +1 -0
- package/dist/chunk-R4O7APIX.js +51 -0
- package/dist/chunk-R4O7APIX.js.map +1 -0
- package/dist/chunk-ROLKRJWI.js +187 -0
- package/dist/chunk-ROLKRJWI.js.map +1 -0
- package/dist/chunk-SOTQ3WP5.js +31 -0
- package/dist/chunk-SOTQ3WP5.js.map +1 -0
- package/dist/chunk-TCFFHTY3.js +97 -0
- package/dist/chunk-TCFFHTY3.js.map +1 -0
- package/dist/chunk-TS7Y7GEZ.js +7 -0
- package/dist/chunk-TS7Y7GEZ.js.map +1 -0
- package/dist/chunk-VLGR5PZX.js +3 -0
- package/dist/chunk-VLGR5PZX.js.map +1 -0
- package/dist/chunk-ZW2PUSVS.js +40 -0
- package/dist/chunk-ZW2PUSVS.js.map +1 -0
- package/dist/chunk-ZYNBMYJ6.js +392 -0
- package/dist/chunk-ZYNBMYJ6.js.map +1 -0
- package/dist/cli/nonext-husk.js +3360 -0
- package/dist/cli/nonext-husk.js.map +1 -0
- package/dist/cli/templates/firestore.rules +393 -0
- package/dist/cli/templates/storage.rules +165 -0
- package/dist/client/index.d.ts +27 -0
- package/dist/client/index.js +15 -0
- package/dist/client/index.js.map +1 -0
- package/dist/cms-9fSA3uQV.d.ts +158 -0
- package/dist/core/index.d.ts +1267 -0
- package/dist/core/index.js +12 -0
- package/dist/core/index.js.map +1 -0
- package/dist/entry-D3kiE8T1.d.ts +163 -0
- package/dist/field-Diyf5siF.d.ts +158 -0
- package/dist/field-type-D25Vojb0.d.ts +157 -0
- package/dist/firebase/index.d.ts +251 -0
- package/dist/firebase/index.js +991 -0
- package/dist/firebase/index.js.map +1 -0
- package/dist/index-5ylaMBUH.d.ts +155 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/index.d.ts +101 -0
- package/dist/permissions/index.js +7 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/registry-CUC25qay.d.ts +59 -0
- package/dist/richtext/index.d.ts +85 -0
- package/dist/richtext/index.js +127 -0
- package/dist/richtext/index.js.map +1 -0
- package/dist/schema/index.d.ts +50 -0
- package/dist/schema/index.js +5 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/server/index.d.ts +39 -0
- package/dist/server/index.js +25 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/dist/write-limits-Djmm6L3R.d.ts +215 -0
- package/package.json +136 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { c as ContentTypeLike, d as ContentTypeSource } from './entry-D3kiE8T1.js';
|
|
2
|
+
|
|
3
|
+
/** A database-defined type that failed validation. Surfaced, never silently dropped. */
|
|
4
|
+
interface InvalidContentType {
|
|
5
|
+
slug: string;
|
|
6
|
+
reason: string;
|
|
7
|
+
definition: unknown;
|
|
8
|
+
}
|
|
9
|
+
interface RegistryEntry {
|
|
10
|
+
type: ContentTypeLike;
|
|
11
|
+
source: ContentTypeSource;
|
|
12
|
+
locked: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* A database-defined type with the same slug as this code-defined one. Code wins, but the
|
|
15
|
+
* database copy is kept so the admin can show the collision instead of silently
|
|
16
|
+
* discarding what an editor created.
|
|
17
|
+
*/
|
|
18
|
+
conflict?: {
|
|
19
|
+
source: "database";
|
|
20
|
+
type: ContentTypeLike;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
interface Registry {
|
|
24
|
+
readonly types: readonly RegistryEntry[];
|
|
25
|
+
readonly conflicts: readonly RegistryEntry[];
|
|
26
|
+
readonly invalid: readonly InvalidContentType[];
|
|
27
|
+
get(slug: string): RegistryEntry | undefined;
|
|
28
|
+
require(slug: string): RegistryEntry;
|
|
29
|
+
has(slug: string): boolean;
|
|
30
|
+
/** Entries whose winning definition came from this source. */
|
|
31
|
+
bySource(source: ContentTypeSource): readonly RegistryEntry[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Merges the two schema sources into one list of content types.
|
|
35
|
+
*
|
|
36
|
+
* Precedence is deterministic and one-directional: **code always wins**. A code-defined
|
|
37
|
+
* type is the customer's committed, reviewable, deployable definition; a database-defined
|
|
38
|
+
* one was created in the admin at runtime. When both define the same slug, the code
|
|
39
|
+
* definition is the live one and the database definition is attached as a conflict so the
|
|
40
|
+
* Schema Builder can show it and offer a resolution. Discarding it silently would lose an
|
|
41
|
+
* editor's work with no trace.
|
|
42
|
+
*
|
|
43
|
+
* A code-defined type that is malformed throws: that is a programming error and should
|
|
44
|
+
* fail at startup. A database-defined type that is malformed is collected into `invalid`
|
|
45
|
+
* instead, because one corrupt stored document must not take the whole admin down.
|
|
46
|
+
*/
|
|
47
|
+
declare function buildRegistry(code: readonly ContentTypeLike[], database: readonly ContentTypeLike[]): Registry;
|
|
48
|
+
/**
|
|
49
|
+
* Domain-level lock enforcement.
|
|
50
|
+
*
|
|
51
|
+
* A locked type may not have its *definition* changed: not from the Schema Builder, not
|
|
52
|
+
* through a direct adapter call, not by a request that skipped the UI. Its entries are
|
|
53
|
+
* edited normally; locking protects the schema, not the content.
|
|
54
|
+
*/
|
|
55
|
+
declare function assertSchemaWritable(registry: Registry, slug: string): RegistryEntry;
|
|
56
|
+
/** True when the Schema Builder should render this type read-only. */
|
|
57
|
+
declare function isSchemaReadOnly(entry: RegistryEntry): boolean;
|
|
58
|
+
|
|
59
|
+
export { type InvalidContentType as I, type Registry as R, type RegistryEntry as a, assertSchemaWritable as b, buildRegistry as c, isSchemaReadOnly as i };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { j as RichTextValue } from '../field-Diyf5siF.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The href to emit, or null when the stored one may not be linked.
|
|
6
|
+
*
|
|
7
|
+
* Relative URLs (`/about`, `#section`, `./thing`) have no scheme and are always safe: they
|
|
8
|
+
* cannot leave the customer's own origin and cannot name a scheme handler.
|
|
9
|
+
*/
|
|
10
|
+
declare function safeHref(value: unknown): string | null;
|
|
11
|
+
interface RichTextProps {
|
|
12
|
+
/** The stored field value. `null` is an unset field and renders nothing. */
|
|
13
|
+
value: RichTextValue | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Renders a rich text field value.
|
|
17
|
+
*
|
|
18
|
+
* It emits no wrapper element, so the surrounding markup and every class name stay in the
|
|
19
|
+
* customer's page:
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* <div className="prose">
|
|
23
|
+
* <RichText value={project.body} />
|
|
24
|
+
* </div>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
declare function RichText({ value }: RichTextProps): ReactNode;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reading a stored rich text document without the editor.
|
|
31
|
+
*
|
|
32
|
+
* Everything here is a pure function over JSON, so the server can answer the same questions
|
|
33
|
+
* the browser does without loading ProseMirror, and so the answers are testable in the node
|
|
34
|
+
* project rather than only through a mounted editor.
|
|
35
|
+
*
|
|
36
|
+
* The rule the whole module exists for: nothing here ever rewrites a document it does not
|
|
37
|
+
* understand. Reading an unreadable document reports why; it does not repair, truncate, or
|
|
38
|
+
* empty it. See .docs/decisions/2026-09-18-richtext-editor.md.
|
|
39
|
+
*/
|
|
40
|
+
/** A ProseMirror node in Tiptap's JSON form, which is what `RichTextValue.content` holds. */
|
|
41
|
+
interface RichTextNodeJSON {
|
|
42
|
+
type?: string;
|
|
43
|
+
attrs?: Record<string, unknown>;
|
|
44
|
+
content?: RichTextNodeJSON[];
|
|
45
|
+
marks?: {
|
|
46
|
+
type: string;
|
|
47
|
+
attrs?: Record<string, unknown>;
|
|
48
|
+
}[];
|
|
49
|
+
text?: string;
|
|
50
|
+
}
|
|
51
|
+
/** Whether a stored value is the `husk-richtext-v1` envelope, regardless of what is inside it. */
|
|
52
|
+
declare function isRichTextValue(raw: unknown): raw is RichTextValue;
|
|
53
|
+
/**
|
|
54
|
+
* The document's text, with block boundaries as newlines.
|
|
55
|
+
*
|
|
56
|
+
* This is what `minLength` and `maxLength` measure. Counting the JSON, or the rendered HTML,
|
|
57
|
+
* would count markup an editor never typed and cannot see.
|
|
58
|
+
*/
|
|
59
|
+
declare function richTextToPlainText(content: unknown): string;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the document says nothing.
|
|
62
|
+
*
|
|
63
|
+
* The editor's cleared state is `{ type: "doc", content: [{ type: "paragraph" }] }`, not an
|
|
64
|
+
* absent value, and the shared validator's `isEmpty` reports `false` for any object. Without
|
|
65
|
+
* this, `required` on a rich text field would be unenforceable. A structure an editor built
|
|
66
|
+
* deliberately, an empty list or an empty quote, is not empty even when it holds no text.
|
|
67
|
+
*/
|
|
68
|
+
declare function isEmptyRichTextContent(content: unknown): boolean;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Husk rich text schema, as plain data.
|
|
72
|
+
*
|
|
73
|
+
* This is the contract `format: "husk-richtext-v1"` names. It is the full set of ProseMirror
|
|
74
|
+
* node and mark types a stored document may contain, and it does not change with a field's
|
|
75
|
+
* `features` list: see .docs/decisions/2026-09-18-richtext-editor.md. Adding a name here is a
|
|
76
|
+
* format change and needs `husk-richtext-v2` plus a migration, not an edit.
|
|
77
|
+
*
|
|
78
|
+
* It lives in its own module with no imports, because two very different callers need it.
|
|
79
|
+
* `document.ts` checks a stored document against it on the server, where dragging in the
|
|
80
|
+
* editor's extension modules would put ProseMirror into a bundle that never renders an
|
|
81
|
+
* editor; `extensions.ts` builds the matching extension array in the browser.
|
|
82
|
+
*/
|
|
83
|
+
declare const RICHTEXT_FORMAT = "husk-richtext-v1";
|
|
84
|
+
|
|
85
|
+
export { RICHTEXT_FORMAT, RichText, type RichTextNodeJSON, type RichTextProps, RichTextValue, isEmptyRichTextContent, isRichTextValue, richTextToPlainText, safeHref };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { renderJSONContentToReactElement } from '@tiptap/static-renderer/json/react';
|
|
2
|
+
import { createElement } from 'react';
|
|
3
|
+
import { jsx, Fragment } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
// src/fields/richtext/render.tsx
|
|
6
|
+
var SAFE_SCHEMES = /* @__PURE__ */ new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
7
|
+
function safeHref(value) {
|
|
8
|
+
if (typeof value !== "string") return null;
|
|
9
|
+
const href = value.trim();
|
|
10
|
+
if (href === "") return null;
|
|
11
|
+
if (href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) return href;
|
|
12
|
+
try {
|
|
13
|
+
return SAFE_SCHEMES.has(new URL(href).protocol) ? href : null;
|
|
14
|
+
} catch {
|
|
15
|
+
return href.includes(":") ? null : href;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function headingLevel(attrs) {
|
|
19
|
+
const level = attrs?.["level"];
|
|
20
|
+
return level === 3 || level === "3" ? 3 : 2;
|
|
21
|
+
}
|
|
22
|
+
function attributeString(attrs, key) {
|
|
23
|
+
const value = attrs?.[key];
|
|
24
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
25
|
+
}
|
|
26
|
+
function passThrough({ children }) {
|
|
27
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
28
|
+
}
|
|
29
|
+
var nodeMapping = {
|
|
30
|
+
doc: ({ children }) => /* @__PURE__ */ jsx(Fragment, { children }),
|
|
31
|
+
paragraph: ({ children }) => /* @__PURE__ */ jsx("p", { children }),
|
|
32
|
+
text: ({ node }) => node.text ?? "",
|
|
33
|
+
heading: ({ node, children }) => createElement(`h${String(headingLevel(node.attrs))}`, null, children),
|
|
34
|
+
bulletList: ({ children }) => /* @__PURE__ */ jsx("ul", { children }),
|
|
35
|
+
orderedList: ({ node, children }) => {
|
|
36
|
+
const start = node.attrs?.["start"];
|
|
37
|
+
return typeof start === "number" && start !== 1 ? /* @__PURE__ */ jsx("ol", { start, children }) : /* @__PURE__ */ jsx("ol", { children });
|
|
38
|
+
},
|
|
39
|
+
listItem: ({ children }) => /* @__PURE__ */ jsx("li", { children }),
|
|
40
|
+
blockquote: ({ children }) => /* @__PURE__ */ jsx("blockquote", { children }),
|
|
41
|
+
hardBreak: () => /* @__PURE__ */ jsx("br", {})
|
|
42
|
+
};
|
|
43
|
+
var markMapping = {
|
|
44
|
+
bold: ({ children }) => /* @__PURE__ */ jsx("strong", { children }),
|
|
45
|
+
italic: ({ children }) => /* @__PURE__ */ jsx("em", { children }),
|
|
46
|
+
strike: ({ children }) => /* @__PURE__ */ jsx("s", { children }),
|
|
47
|
+
link: ({ mark, children }) => {
|
|
48
|
+
const href = safeHref(mark.attrs?.["href"]);
|
|
49
|
+
if (href === null) return /* @__PURE__ */ jsx(Fragment, { children });
|
|
50
|
+
const target = attributeString(mark.attrs, "target");
|
|
51
|
+
return target === void 0 ? /* @__PURE__ */ jsx("a", { href, children }) : /* @__PURE__ */ jsx("a", { href, target, rel: "noreferrer noopener", children });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
var renderDocument = renderJSONContentToReactElement({
|
|
55
|
+
nodeMapping,
|
|
56
|
+
markMapping,
|
|
57
|
+
unhandledNode: passThrough,
|
|
58
|
+
unhandledMark: passThrough
|
|
59
|
+
});
|
|
60
|
+
function RichText({ value }) {
|
|
61
|
+
if (value === null) return null;
|
|
62
|
+
const content = value.content;
|
|
63
|
+
if (typeof content !== "object" || content === null) return null;
|
|
64
|
+
return renderDocument({ content });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// src/fields/richtext/schema.ts
|
|
68
|
+
var RICHTEXT_FORMAT = "husk-richtext-v1";
|
|
69
|
+
var RICHTEXT_NODES = [
|
|
70
|
+
"doc",
|
|
71
|
+
"paragraph",
|
|
72
|
+
"text",
|
|
73
|
+
"heading",
|
|
74
|
+
"bulletList",
|
|
75
|
+
"orderedList",
|
|
76
|
+
"listItem",
|
|
77
|
+
"blockquote",
|
|
78
|
+
"hardBreak"
|
|
79
|
+
];
|
|
80
|
+
var RICHTEXT_MARKS = ["bold", "italic", "strike", "link"];
|
|
81
|
+
|
|
82
|
+
// src/fields/richtext/document.ts
|
|
83
|
+
var STRUCTURAL_ONLY = /* @__PURE__ */ new Set(["doc", "paragraph", "text", "hardBreak"]);
|
|
84
|
+
var BLOCK_NODES = /* @__PURE__ */ new Set(["paragraph", "heading", "listItem", "blockquote"]);
|
|
85
|
+
new Set(RICHTEXT_NODES);
|
|
86
|
+
new Set(RICHTEXT_MARKS);
|
|
87
|
+
function isRecord(value) {
|
|
88
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
function isRichTextValue(raw) {
|
|
91
|
+
return isRecord(raw) && raw["format"] === RICHTEXT_FORMAT && "content" in raw;
|
|
92
|
+
}
|
|
93
|
+
function richTextToPlainText(content) {
|
|
94
|
+
if (!isRecord(content)) return "";
|
|
95
|
+
const parts = [];
|
|
96
|
+
function walk(node) {
|
|
97
|
+
if (typeof node["text"] === "string") parts.push(node["text"]);
|
|
98
|
+
else if (node["type"] === "hardBreak") parts.push("\n");
|
|
99
|
+
const children = node["content"];
|
|
100
|
+
if (Array.isArray(children)) {
|
|
101
|
+
for (const child of children) if (isRecord(child)) walk(child);
|
|
102
|
+
}
|
|
103
|
+
if (typeof node["type"] === "string" && BLOCK_NODES.has(node["type"]))
|
|
104
|
+
parts.push("\n");
|
|
105
|
+
}
|
|
106
|
+
walk(content);
|
|
107
|
+
return parts.join("").replace(/\n{2,}/g, "\n").replace(/^\n+|\n+$/g, "");
|
|
108
|
+
}
|
|
109
|
+
function isEmptyRichTextContent(content) {
|
|
110
|
+
if (!isRecord(content)) return true;
|
|
111
|
+
if (richTextToPlainText(content).trim() !== "") return false;
|
|
112
|
+
let structuralOnly = true;
|
|
113
|
+
function walk(node) {
|
|
114
|
+
const type = node["type"];
|
|
115
|
+
if (typeof type === "string" && !STRUCTURAL_ONLY.has(type)) structuralOnly = false;
|
|
116
|
+
const children = node["content"];
|
|
117
|
+
if (Array.isArray(children)) {
|
|
118
|
+
for (const child of children) if (isRecord(child)) walk(child);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
walk(content);
|
|
122
|
+
return structuralOnly;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { RICHTEXT_FORMAT, RichText, isEmptyRichTextContent, isRichTextValue, richTextToPlainText, safeHref };
|
|
126
|
+
//# sourceMappingURL=index.js.map
|
|
127
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/fields/richtext/render.tsx","../../src/fields/richtext/schema.ts","../../src/fields/richtext/document.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { renderJSONContentToReactElement } from \"@tiptap/static-renderer/json/react\"\nimport { createElement } from \"react\"\nimport type { ReactNode } from \"react\"\n\nimport type { RichTextValue } from \"../../types/field.js\"\n\n/**\n * Rendering a stored rich text document on a website.\n *\n * This is the read half of the rich text field and the only part of it a customer's public\n * page ever loads. It maps the stored ProseMirror JSON to React elements through\n * `@tiptap/static-renderer`, which walks the JSON with a node and mark mapping and never\n * constructs a ProseMirror schema, an `Editor`, or a DOM. The editor itself (Tiptap,\n * ProseMirror, the toolbar, 123 KB gzip of it) stays in the admin bundle where it belongs.\n * That split is the reason the editor decision went the way it did; see\n * .docs/decisions/2026-09-18-richtext-editor.md.\n *\n * React elements rather than an HTML string, deliberately. The string renderer would have to\n * be handed to `dangerouslySetInnerHTML`, which means every future bug in this file, in the\n * renderer, or in an editor's paste handling is an XSS in a customer's website. Elements\n * cannot inject markup: React escapes text, and the only attribute this file emits from\n * stored data is a link's `href`, which is checked against an allowlist of schemes below.\n *\n * It emits no class names and no styling. The document is content; how body copy looks is\n * the customer's stylesheet, and Husk is not a page builder.\n */\n\n/** A stored node, in the shape the renderer hands to a mapping. */\ninterface RenderedNode {\n type?: string\n attrs?: Record<string, unknown>\n text?: string\n}\n\ninterface RenderedMark {\n type: string\n attrs?: Record<string, unknown>\n}\n\ninterface NodeContext {\n node: RenderedNode\n children?: ReactNode\n}\n\ninterface MarkContext {\n mark: RenderedMark\n children?: ReactNode\n}\n\n/**\n * URL schemes a stored link may use.\n *\n * An allowlist, because the interesting cases are the ones nobody thinks of: `javascript:`\n * is the obvious one, `data:` carries a whole document, and `vbscript:` still exists. The\n * editor's Link extension refuses those on input, but input validation is not a property of\n * data that has been sitting in a database, possibly written by an older version or by a\n * script. Anything not on this list renders as plain text with the formatting kept.\n */\nconst SAFE_SCHEMES = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\"])\n\n/**\n * The href to emit, or null when the stored one may not be linked.\n *\n * Relative URLs (`/about`, `#section`, `./thing`) have no scheme and are always safe: they\n * cannot leave the customer's own origin and cannot name a scheme handler.\n */\nexport function safeHref(value: unknown): string | null {\n if (typeof value !== \"string\") return null\n const href = value.trim()\n if (href === \"\") return null\n if (href.startsWith(\"/\") || href.startsWith(\"#\") || href.startsWith(\"?\")) return href\n\n // A scheme is what makes a URL dangerous, so the question is only ever \"which scheme\".\n // `URL` parsing answers it without a regex that has to be right about every edge.\n try {\n return SAFE_SCHEMES.has(new URL(href).protocol) ? href : null\n } catch {\n // Not an absolute URL at all, which means a relative path such as \"about/us\".\n return href.includes(\":\") ? null : href\n }\n}\n\nfunction headingLevel(attrs: Record<string, unknown> | undefined): 2 | 3 {\n const level = attrs?.[\"level\"]\n // The schema has two levels. A document carrying anything else renders as the outermost\n // one rather than as an `<h7>`: the text is the content, the number was never the point.\n return level === 3 || level === \"3\" ? 3 : 2\n}\n\nfunction attributeString(\n attrs: Record<string, unknown> | undefined,\n key: string,\n): string | undefined {\n const value = attrs?.[key]\n return typeof value === \"string\" && value !== \"\" ? value : undefined\n}\n\n/**\n * Passing children through, for a node or a mark this version does not know.\n *\n * A document written by a newer version of the package than the one deployed must not take\n * a page down, and must not vanish either. Dropping the wrapper and keeping its children\n * loses the formatting and keeps the words, which is the least bad of the three outcomes.\n * The admin is where an unreadable document is reported, by `inspectRichText`; a website is\n * not the place to tell a visitor about a schema mismatch.\n */\nfunction passThrough({ children }: NodeContext | MarkContext): ReactNode {\n return <>{children}</>\n}\n\nconst nodeMapping = {\n doc: ({ children }: NodeContext) => <>{children}</>,\n paragraph: ({ children }: NodeContext) => <p>{children}</p>,\n text: ({ node }: NodeContext) => node.text ?? \"\",\n heading: ({ node, children }: NodeContext) =>\n createElement(`h${String(headingLevel(node.attrs))}`, null, children),\n bulletList: ({ children }: NodeContext) => <ul>{children}</ul>,\n orderedList: ({ node, children }: NodeContext) => {\n const start = node.attrs?.[\"start\"]\n return typeof start === \"number\" && start !== 1 ? (\n <ol start={start}>{children}</ol>\n ) : (\n <ol>{children}</ol>\n )\n },\n listItem: ({ children }: NodeContext) => <li>{children}</li>,\n blockquote: ({ children }: NodeContext) => <blockquote>{children}</blockquote>,\n hardBreak: () => <br />,\n}\n\nconst markMapping = {\n bold: ({ children }: MarkContext) => <strong>{children}</strong>,\n italic: ({ children }: MarkContext) => <em>{children}</em>,\n strike: ({ children }: MarkContext) => <s>{children}</s>,\n link: ({ mark, children }: MarkContext) => {\n const href = safeHref(mark.attrs?.[\"href\"])\n if (href === null) return <>{children}</>\n\n const target = attributeString(mark.attrs, \"target\")\n // A link that opens a new tab hands the opener to the destination unless it is told not\n // to. The stored `rel` is not trusted for this: it is authored data.\n return target === undefined ? (\n <a href={href}>{children}</a>\n ) : (\n <a href={href} target={target} rel=\"noreferrer noopener\">\n {children}\n </a>\n )\n },\n}\n\nconst renderDocument = renderJSONContentToReactElement({\n nodeMapping,\n markMapping,\n unhandledNode: passThrough,\n unhandledMark: passThrough,\n})\n\nexport interface RichTextProps {\n /** The stored field value. `null` is an unset field and renders nothing. */\n value: RichTextValue | null\n}\n\n/**\n * Renders a rich text field value.\n *\n * It emits no wrapper element, so the surrounding markup and every class name stay in the\n * customer's page:\n *\n * ```tsx\n * <div className=\"prose\">\n * <RichText value={project.body} />\n * </div>\n * ```\n */\nexport function RichText({ value }: RichTextProps): ReactNode {\n if (value === null) return null\n\n const content = value.content\n if (typeof content !== \"object\" || content === null) return null\n\n return renderDocument({ content: content as { type?: string } })\n}\n","/**\n * The Husk rich text schema, as plain data.\n *\n * This is the contract `format: \"husk-richtext-v1\"` names. It is the full set of ProseMirror\n * node and mark types a stored document may contain, and it does not change with a field's\n * `features` list: see .docs/decisions/2026-09-18-richtext-editor.md. Adding a name here is a\n * format change and needs `husk-richtext-v2` plus a migration, not an edit.\n *\n * It lives in its own module with no imports, because two very different callers need it.\n * `document.ts` checks a stored document against it on the server, where dragging in the\n * editor's extension modules would put ProseMirror into a bundle that never renders an\n * editor; `extensions.ts` builds the matching extension array in the browser.\n */\n\nexport const RICHTEXT_FORMAT = \"husk-richtext-v1\"\n\nexport const RICHTEXT_NODES = [\n \"doc\",\n \"paragraph\",\n \"text\",\n \"heading\",\n \"bulletList\",\n \"orderedList\",\n \"listItem\",\n \"blockquote\",\n \"hardBreak\",\n] as const\n\nexport const RICHTEXT_MARKS = [\"bold\", \"italic\", \"strike\", \"link\"] as const\n\n/** The only heading levels in the schema. A rich text field is body copy, not a page. */\nexport const RICHTEXT_HEADING_LEVELS = [2, 3] as const\n\nexport type RichtextNodeName = (typeof RICHTEXT_NODES)[number]\nexport type RichtextMarkName = (typeof RICHTEXT_MARKS)[number]\n\n/**\n * What a `RichtextField.features` list may name.\n *\n * A feature hides a toolbar control. It never removes a node or a mark from the schema, so a\n * document written while a feature was on stays readable, storable and renderable after it is\n * switched off. Undo and redo are not features: an editor always gets them.\n */\nexport const RICHTEXT_FEATURES = [\n \"bold\",\n \"italic\",\n \"strike\",\n \"heading\",\n \"blockquote\",\n \"bulletList\",\n \"orderedList\",\n \"link\",\n] as const\n\nexport type RichtextFeature = (typeof RICHTEXT_FEATURES)[number]\n\nexport function isRichtextFeature(value: string): value is RichtextFeature {\n return (RICHTEXT_FEATURES as readonly string[]).includes(value)\n}\n\n/**\n * Resolves the feature list a field asks for.\n *\n * An absent list means every feature, per `RichtextField`. An unknown name is dropped rather\n * than thrown on, because the Schema Builder lets an editor type one in and a typo must not\n * take the entry form down. An empty result is legitimate: a field with `features: []` is a\n * plain-paragraph field, and the toolbar still renders undo and redo.\n */\nexport function resolveFeatures(\n features: readonly string[] | undefined,\n): readonly RichtextFeature[] {\n if (features === undefined) return RICHTEXT_FEATURES\n return features.filter(isRichtextFeature)\n}\n","import { RICHTEXT_FORMAT, RICHTEXT_MARKS, RICHTEXT_NODES } from \"./schema.js\"\nimport type { RichTextValue } from \"../../types/field.js\"\n\n/**\n * Reading a stored rich text document without the editor.\n *\n * Everything here is a pure function over JSON, so the server can answer the same questions\n * the browser does without loading ProseMirror, and so the answers are testable in the node\n * project rather than only through a mounted editor.\n *\n * The rule the whole module exists for: nothing here ever rewrites a document it does not\n * understand. Reading an unreadable document reports why; it does not repair, truncate, or\n * empty it. See .docs/decisions/2026-09-18-richtext-editor.md.\n */\n\n/** A ProseMirror node in Tiptap's JSON form, which is what `RichTextValue.content` holds. */\nexport interface RichTextNodeJSON {\n type?: string\n attrs?: Record<string, unknown>\n content?: RichTextNodeJSON[]\n marks?: { type: string; attrs?: Record<string, unknown> }[]\n text?: string\n}\n\n/** Node types that carry no meaning of their own when they hold no text. */\nconst STRUCTURAL_ONLY = new Set([\"doc\", \"paragraph\", \"text\", \"hardBreak\"])\n\n/** Node types that end a line when their text is read out. */\nconst BLOCK_NODES = new Set([\"paragraph\", \"heading\", \"listItem\", \"blockquote\"])\n\nconst NODE_NAMES: ReadonlySet<string> = new Set(RICHTEXT_NODES)\nconst MARK_NAMES: ReadonlySet<string> = new Set(RICHTEXT_MARKS)\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n}\n\n/** The document a new rich text field starts on, and the one the editor produces when cleared. */\nexport function emptyRichTextContent(): RichTextNodeJSON {\n return { type: \"doc\", content: [{ type: \"paragraph\" }] }\n}\n\n/** Whether a stored value is the `husk-richtext-v1` envelope, regardless of what is inside it. */\nexport function isRichTextValue(raw: unknown): raw is RichTextValue {\n return isRecord(raw) && raw[\"format\"] === RICHTEXT_FORMAT && \"content\" in raw\n}\n\n/**\n * The document's text, with block boundaries as newlines.\n *\n * This is what `minLength` and `maxLength` measure. Counting the JSON, or the rendered HTML,\n * would count markup an editor never typed and cannot see.\n */\nexport function richTextToPlainText(content: unknown): string {\n if (!isRecord(content)) return \"\"\n\n const parts: string[] = []\n\n function walk(node: Record<string, unknown>): void {\n if (typeof node[\"text\"] === \"string\") parts.push(node[\"text\"])\n else if (node[\"type\"] === \"hardBreak\") parts.push(\"\\n\")\n\n const children = node[\"content\"]\n if (Array.isArray(children)) {\n for (const child of children) if (isRecord(child)) walk(child)\n }\n\n // A block ends a line, so two paragraphs do not measure as one run-on word.\n if (typeof node[\"type\"] === \"string\" && BLOCK_NODES.has(node[\"type\"]))\n parts.push(\"\\n\")\n }\n\n walk(content)\n return parts\n .join(\"\")\n .replace(/\\n{2,}/g, \"\\n\")\n .replace(/^\\n+|\\n+$/g, \"\")\n}\n\n/**\n * Whether the document says nothing.\n *\n * The editor's cleared state is `{ type: \"doc\", content: [{ type: \"paragraph\" }] }`, not an\n * absent value, and the shared validator's `isEmpty` reports `false` for any object. Without\n * this, `required` on a rich text field would be unenforceable. A structure an editor built\n * deliberately, an empty list or an empty quote, is not empty even when it holds no text.\n */\nexport function isEmptyRichTextContent(content: unknown): boolean {\n if (!isRecord(content)) return true\n if (richTextToPlainText(content).trim() !== \"\") return false\n\n let structuralOnly = true\n function walk(node: Record<string, unknown>): void {\n const type = node[\"type\"]\n if (typeof type === \"string\" && !STRUCTURAL_ONLY.has(type)) structuralOnly = false\n const children = node[\"content\"]\n if (Array.isArray(children)) {\n for (const child of children) if (isRecord(child)) walk(child)\n }\n }\n walk(content)\n\n return structuralOnly\n}\n\nexport type RichTextReadState =\n { kind: \"readable\"; content: RichTextNodeJSON } | { kind: \"unreadable\"; reason: string }\n\n/**\n * Can this document be opened in the editor without losing anything?\n *\n * Every node and mark name has to be one the Husk schema declares. A name outside it would be\n * dropped silently by ProseMirror on parse, and the next save would write the stripped version\n * over the original. Reporting it instead is the entire point.\n *\n * `null` is readable: an unset field opens on an empty document.\n */\nexport function inspectRichText(value: RichTextValue | null): RichTextReadState {\n if (value === null) return { kind: \"readable\", content: emptyRichTextContent() }\n\n const { content } = value\n if (!isRecord(content)) {\n return { kind: \"unreadable\", reason: \"its content is not a document\" }\n }\n if (content[\"type\"] !== \"doc\") {\n return {\n kind: \"unreadable\",\n reason:\n typeof content[\"type\"] === \"string\"\n ? `it starts with \"${content[\"type\"]}\" instead of a document`\n : \"it has no document at its root\",\n }\n }\n\n const unknown = findUnknownName(content)\n if (unknown !== null) {\n return {\n kind: \"unreadable\",\n reason: `it uses ${unknown}, which this version cannot open`,\n }\n }\n\n return { kind: \"readable\", content: content as RichTextNodeJSON }\n}\n\nfunction findUnknownName(node: Record<string, unknown>): string | null {\n const type = node[\"type\"]\n if (typeof type !== \"string\") return \"a node with no type\"\n if (!NODE_NAMES.has(type)) return `the node type \"${type}\"`\n\n const marks = node[\"marks\"]\n if (Array.isArray(marks)) {\n for (const mark of marks) {\n if (!isRecord(mark)) return \"a mark with no type\"\n const markType = mark[\"type\"]\n if (typeof markType !== \"string\") return \"a mark with no type\"\n if (!MARK_NAMES.has(markType)) return `the mark \"${markType}\"`\n }\n }\n\n const children = node[\"content\"]\n if (Array.isArray(children)) {\n for (const child of children) {\n if (!isRecord(child)) return \"a node that is not an object\"\n const found = findUnknownName(child)\n if (found !== null) return found\n }\n }\n\n return null\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { f as FieldTypeRegistry } from '../field-type-D25Vojb0.js';
|
|
2
|
+
export { A as AnyFieldTypeSpec, F as FieldEditorSpec, a as FieldInputComponent, b as FieldInputProps, c as FieldOptionControl, d as FieldOptionDescriptor, e as FieldTsType, g as FieldTypeSpec, N as NestedConverter, T as TsTypeResolver, U as UNKNOWN_TS_TYPE, V as ValidateCtx, r as registerFieldTypes, h as resolveTsType } from '../field-type-D25Vojb0.js';
|
|
3
|
+
import { A as AnyContentTypeShape, F as FieldError } from '../entry-D3kiE8T1.js';
|
|
4
|
+
import 'react';
|
|
5
|
+
import '../field-Diyf5siF.js';
|
|
6
|
+
import '../auth-B2gBtHl5.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One validation pass over an entry.
|
|
10
|
+
*
|
|
11
|
+
* This is the only validator in the product. The admin form runs it on change and on
|
|
12
|
+
* submit; the adapter runs it again before every write. A rule enforced in one place only
|
|
13
|
+
* is a rule that is not enforced.
|
|
14
|
+
*
|
|
15
|
+
* `unique` cannot be answered without reading other entries, so it is not checked here.
|
|
16
|
+
* `uniqueFieldPaths()` lists the fields the adapter must check, and the adapter reports the
|
|
17
|
+
* failure with the same `FieldError` shape, so the form renders it identically.
|
|
18
|
+
*/
|
|
19
|
+
interface ValidateOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Skip `required`. Used when saving a draft, where an incomplete entry is the point.
|
|
22
|
+
* Every other rule still applies: a value that is present must still be valid.
|
|
23
|
+
*/
|
|
24
|
+
skipRequired?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The registered field types, so each field's own `validate` runs alongside the shared
|
|
27
|
+
* rules. Optional, because the rules below stand on their own and a database-defined type
|
|
28
|
+
* using an unregistered field type must still validate rather than throw.
|
|
29
|
+
*
|
|
30
|
+
* The two halves do not overlap by construction: everything expressible as a
|
|
31
|
+
* `ValidationRules` key is checked here, once, and a spec adds only what its own field
|
|
32
|
+
* definition knows (a number's `step`, a date's precision, a slug's shape).
|
|
33
|
+
*/
|
|
34
|
+
fieldTypes?: FieldTypeRegistry;
|
|
35
|
+
}
|
|
36
|
+
declare function validateEntry(type: AnyContentTypeShape, values: Record<string, unknown>, options?: ValidateOptions): FieldError[];
|
|
37
|
+
/**
|
|
38
|
+
* Field keys carrying `unique`, for the adapter to check against stored entries. Fields
|
|
39
|
+
* nested in a repeater are excluded: uniqueness across repeater rows is not a constraint
|
|
40
|
+
* this CMS offers, and pretending otherwise would need a query no index supports.
|
|
41
|
+
*/
|
|
42
|
+
declare function uniqueFieldPaths(type: AnyContentTypeShape): string[];
|
|
43
|
+
/** Patterns the Schema Builder offers as presets, so editors do not hand-write regexes. */
|
|
44
|
+
declare const PATTERN_PRESETS: {
|
|
45
|
+
readonly email: "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$";
|
|
46
|
+
readonly slug: "^[a-z0-9]+(?:-[a-z0-9]+)*$";
|
|
47
|
+
readonly url: "^https?://[^\\s]+$";
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export { FieldTypeRegistry, PATTERN_PRESETS, type ValidateOptions, uniqueFieldPaths, validateEntry };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { UNKNOWN_TS_TYPE, registerFieldTypes, resolveTsType } from '../chunk-ZW2PUSVS.js';
|
|
2
|
+
export { PATTERN_PRESETS, uniqueFieldPaths, validateEntry } from '../chunk-FHYWJCG6.js';
|
|
3
|
+
import '../chunk-4Z6P3XCE.js';
|
|
4
|
+
//# sourceMappingURL=index.js.map
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { f as ContentTypeMap, C as Cms, a as CmsConfig } from '../cms-9fSA3uQV.js';
|
|
2
|
+
export { b as CmsCountQuery, c as CmsQuery, e as CollectionReader, E as EntryOf, M as MediaReader, S as SettingsReader, g as SingleReader } from '../cms-9fSA3uQV.js';
|
|
3
|
+
import '../entry-D3kiE8T1.js';
|
|
4
|
+
import '../field-Diyf5siF.js';
|
|
5
|
+
import '../auth-B2gBtHl5.js';
|
|
6
|
+
import '../adapter-C_0mhENy.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `@nonext/husk/server` surface: the read SDK for server components, route handlers,
|
|
10
|
+
* and anything else that runs on the customer's server.
|
|
11
|
+
*
|
|
12
|
+
* What it adds over the neutral `createCMS` is preview. That is the entire difference, and
|
|
13
|
+
* it is a real one: preview reads unpublished content, so the decision to turn it on has to
|
|
14
|
+
* be made somewhere a visitor cannot reach. A server component can consult
|
|
15
|
+
* `draftMode()`, a signed cookie, or an environment flag; a browser bundle can consult
|
|
16
|
+
* whatever an attacker edited.
|
|
17
|
+
*
|
|
18
|
+
* This entry imports no React, no Next, and no Firebase. The adapter is passed in, which is
|
|
19
|
+
* what keeps the storage dependency in the host's hands and this module free of it.
|
|
20
|
+
*/
|
|
21
|
+
interface ServerCms<TTypes extends ContentTypeMap = ContentTypeMap> extends Cms<TTypes> {
|
|
22
|
+
/**
|
|
23
|
+
* The same instance, reading every status.
|
|
24
|
+
*
|
|
25
|
+
* A second object rather than a mode switch on this one: an instance that could be
|
|
26
|
+
* flipped would mean any code holding a reference could flip it, and the published-only
|
|
27
|
+
* default would hold only until something forgot.
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* const { isEnabled } = await draftMode()
|
|
31
|
+
* const reader = isEnabled ? cms.preview() : cms
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
preview(): ServerCms<TTypes>;
|
|
35
|
+
}
|
|
36
|
+
type ServerCmsConfig<TTypes extends ContentTypeMap = ContentTypeMap> = CmsConfig<TTypes>;
|
|
37
|
+
declare function createServerCMS<TTypes extends ContentTypeMap = ContentTypeMap>(config: ServerCmsConfig<TTypes>): ServerCms<TTypes>;
|
|
38
|
+
|
|
39
|
+
export { Cms, ContentTypeMap, type ServerCms, type ServerCmsConfig, createServerCMS };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createCMS } from '../chunk-3I6337WJ.js';
|
|
2
|
+
import '../chunk-R4O7APIX.js';
|
|
3
|
+
import '../chunk-4Z6P3XCE.js';
|
|
4
|
+
|
|
5
|
+
// src/server/index.ts
|
|
6
|
+
function assertServerSide() {
|
|
7
|
+
if (typeof window !== "undefined") {
|
|
8
|
+
throw new Error(
|
|
9
|
+
"Preview mode was enabled in a browser. A preview reader returns unpublished entries and may only be built on the server. Use createClientCMS from @nonext/husk/client instead."
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function createServerCMS(config) {
|
|
14
|
+
if (config.preview === true) assertServerSide();
|
|
15
|
+
const cms = createCMS(config);
|
|
16
|
+
const server = {
|
|
17
|
+
...cms,
|
|
18
|
+
preview: () => cms.isPreview ? server : createServerCMS({ ...config, preview: true })
|
|
19
|
+
};
|
|
20
|
+
return server;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { createServerCMS };
|
|
24
|
+
//# sourceMappingURL=index.js.map
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/server/index.ts"],"names":[],"mappings":";;;;;AA+CA,SAAS,gBAAA,GAAyB;AAChC,EAAA,IAAI,OAAO,WAAW,WAAA,EAAa;AACjC,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AACF;AAEO,SAAS,gBACd,MAAA,EACmB;AACnB,EAAA,IAAI,MAAA,CAAO,OAAA,KAAY,IAAA,EAAM,gBAAA,EAAiB;AAE9C,EAAA,MAAM,GAAA,GAAM,UAAU,MAAM,CAAA;AAE5B,EAAA,MAAM,MAAA,GAA4B;AAAA,IAChC,GAAG,GAAA;AAAA,IACH,OAAA,EAAS,MACP,GAAA,CAAI,SAAA,GAAY,MAAA,GAAS,eAAA,CAAgB,EAAE,GAAG,MAAA,EAAQ,OAAA,EAAS,IAAA,EAAM;AAAA,GACzE;AAEA,EAAA,OAAO,MAAA;AACT","file":"index.js","sourcesContent":["import { createCMS } from \"../core/cms.js\"\nimport type { Cms, CmsConfig, ContentTypeMap } from \"../core/cms.js\"\n\n/**\n * The `@nonext/husk/server` surface: the read SDK for server components, route handlers,\n * and anything else that runs on the customer's server.\n *\n * What it adds over the neutral `createCMS` is preview. That is the entire difference, and\n * it is a real one: preview reads unpublished content, so the decision to turn it on has to\n * be made somewhere a visitor cannot reach. A server component can consult\n * `draftMode()`, a signed cookie, or an environment flag; a browser bundle can consult\n * whatever an attacker edited.\n *\n * This entry imports no React, no Next, and no Firebase. The adapter is passed in, which is\n * what keeps the storage dependency in the host's hands and this module free of it.\n */\n\nexport interface ServerCms<\n TTypes extends ContentTypeMap = ContentTypeMap,\n> extends Cms<TTypes> {\n /**\n * The same instance, reading every status.\n *\n * A second object rather than a mode switch on this one: an instance that could be\n * flipped would mean any code holding a reference could flip it, and the published-only\n * default would hold only until something forgot.\n *\n * ```ts\n * const { isEnabled } = await draftMode()\n * const reader = isEnabled ? cms.preview() : cms\n * ```\n */\n preview(): ServerCms<TTypes>\n}\n\nexport type ServerCmsConfig<TTypes extends ContentTypeMap = ContentTypeMap> =\n CmsConfig<TTypes>\n\n/**\n * Refuses to build a preview reader in a browser.\n *\n * Belt as well as braces. `@nonext/husk/client` offers no preview option at all, so the\n * normal way of reaching one does not exist there; this catches the abnormal way, which is\n * a server module bundled into a client component by accident. It throws rather than\n * downgrading to published-only, because silently reading different content than the code\n * asked for is how a preview bug becomes a leak nobody notices.\n */\nfunction assertServerSide(): void {\n if (typeof window !== \"undefined\") {\n throw new Error(\n \"Preview mode was enabled in a browser. A preview reader returns unpublished entries and may only be built on the server. Use createClientCMS from @nonext/husk/client instead.\",\n )\n }\n}\n\nexport function createServerCMS<TTypes extends ContentTypeMap = ContentTypeMap>(\n config: ServerCmsConfig<TTypes>,\n): ServerCms<TTypes> {\n if (config.preview === true) assertServerSide()\n\n const cms = createCMS(config)\n\n const server: ServerCms<TTypes> = {\n ...cms,\n preview: () =>\n cms.isPreview ? server : createServerCMS({ ...config, preview: true }),\n }\n\n return server\n}\n\nexport type {\n Cms,\n CmsCountQuery,\n CmsQuery,\n CollectionReader,\n ContentTypeMap,\n EntryOf,\n MediaReader,\n SettingsReader,\n SingleReader,\n} from \"../core/cms.js\"\n"]}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { A as Action, a as AdminAction, b as AuthUser, E as ENTRY_ACTIONS, c as EntryAction, P as Permission, d as PermissionMatrix, R as ROLES, e as Role } from '../auth-B2gBtHl5.js';
|
|
2
|
+
export { B as BooleanField, D as DateField, F as FIELD_TYPES, a as FieldDefinition, b as FieldInputValues, c as FieldMap, d as FieldType, e as FieldValue, f as FieldValues, g as FileField, I as ImageField, M as MediaItem, h as MediaReference, N as NumberField, R as RelationField, i as RepeaterField, j as RichTextValue, k as RichtextField, S as SelectField, l as SelectOption, m as SlugField, T as TextField, n as TextareaField, V as ValidationRules } from '../field-Diyf5siF.js';
|
|
3
|
+
export { A as AnyContentTypeShape, C as ContentType, a as ContentTypeInputValues, b as ContentTypeKind, c as ContentTypeLike, d as ContentTypeSource, e as ContentTypeValues, E as ENTRY_STATUSES, f as Entry, g as EntryInput, h as EntryMeta, i as EntryStatus, F as FieldError, R as RESERVED_FIELD_KEYS, j as RemovedField, k as RemovedFieldMap, l as ReservedFieldKey } from '../entry-D3kiE8T1.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What `cms.config.ts` records about the scaffold it came from.
|
|
7
|
+
*
|
|
8
|
+
* It is not read at runtime by the CMS. The admin, the adapter and the SDK are configured
|
|
9
|
+
* by their own arguments, and nothing in the package reaches out for a config file, which
|
|
10
|
+
* is what keeps `@nonext/husk` usable in a project that never ran the CLI.
|
|
11
|
+
*
|
|
12
|
+
* It exists so that `nonext-husk doctor` can answer questions a directory of files cannot
|
|
13
|
+
* otherwise be asked: which version generated this app, where the admin was mounted, and
|
|
14
|
+
* whether media and authentication were part of the scaffold. Version skew is the one that
|
|
15
|
+
* matters most: the generated route files are a snapshot of what the package expected at
|
|
16
|
+
* the moment `init` ran, and an installed package two minor versions ahead of them can be
|
|
17
|
+
* ahead of them in ways nothing else notices until a screen fails to render.
|
|
18
|
+
*/
|
|
19
|
+
interface HuskConfig {
|
|
20
|
+
/** The `@nonext/husk` version `nonext-husk init` ran from. */
|
|
21
|
+
generatedWith: string;
|
|
22
|
+
/** Where the admin was mounted, with a leading slash and no trailing one. */
|
|
23
|
+
adminRoute: string;
|
|
24
|
+
/** The Firebase project the scaffold was pointed at. */
|
|
25
|
+
projectId: string;
|
|
26
|
+
/** The Firebase Storage bucket the media library uploads to. */
|
|
27
|
+
storageBucket: string;
|
|
28
|
+
/** The media library was scaffolded. */
|
|
29
|
+
media: boolean;
|
|
30
|
+
/** The sign-in screen and the session route were scaffolded. */
|
|
31
|
+
auth: boolean;
|
|
32
|
+
/** The generated Firebase clients target the Emulator Suite. */
|
|
33
|
+
emulators: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type { HuskConfig };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import '../chunk-VLGR5PZX.js';
|
|
2
|
+
export { ENTRY_STATUSES } from '../chunk-ORMYARXD.js';
|
|
3
|
+
export { FIELD_TYPES, RESERVED_FIELD_KEYS } from '../chunk-SOTQ3WP5.js';
|
|
4
|
+
export { ENTRY_ACTIONS, ROLES } from '../chunk-TS7Y7GEZ.js';
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|