@aphexcms/plugin-forms 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 +52 -0
- package/dist/compile.d.ts +75 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +119 -0
- package/dist/compile.js.map +1 -0
- package/dist/events.d.ts +18 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +19 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +17 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +76 -0
- package/dist/notify.js.map +1 -0
- package/dist/schema.d.ts +14 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +492 -0
- package/dist/schema.js.map +1 -0
- package/dist/submit.d.ts +4 -0
- package/dist/submit.d.ts.map +1 -0
- package/dist/submit.js +213 -0
- package/dist/submit.js.map +1 -0
- package/dist/template.d.ts +36 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +51 -0
- package/dist/template.js.map +1 -0
- package/package.json +59 -0
package/dist/submit.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { validateFormData } from '@aphexcms/cms-core';
|
|
3
|
+
import { systemContext } from '@aphexcms/cms-core/local-api/auth-helpers';
|
|
4
|
+
import { compileForm, isInputField } from './compile.js';
|
|
5
|
+
import { fieldLabels, toStoredValue } from './template.js';
|
|
6
|
+
import { formSubmissionCreated } from './events.js';
|
|
7
|
+
/**
|
|
8
|
+
* `POST /api/form-submissions` — the one public surface this plugin opens.
|
|
9
|
+
*
|
|
10
|
+
* Everything here treats the request as hostile, because it is: this is the only
|
|
11
|
+
* endpoint in the CMS an anonymous visitor is *meant* to write through.
|
|
12
|
+
*
|
|
13
|
+
* - The submitted values are validated against the form's own fields, using the
|
|
14
|
+
* same engine the admin uses (see `compile.ts`). A select can only hold an
|
|
15
|
+
* option the form offers; an email field must hold an email.
|
|
16
|
+
* - Only fields the form declares are stored. A key the form doesn't define is
|
|
17
|
+
* dropped rather than persisted, so a crafted request can't stuff arbitrary
|
|
18
|
+
* data into a document.
|
|
19
|
+
* - The submission is written under `systemContext`, which bypasses RBAC — the
|
|
20
|
+
* visitor has no account. That is safe only because the shape written here is
|
|
21
|
+
* fully determined by the form document, never by the request.
|
|
22
|
+
* - No email is sent from this path. A submission is stored and an event is
|
|
23
|
+
* emitted; the notification runs as a durable job, so a mail outage can't fail
|
|
24
|
+
* a visitor's submit and a retry can't re-store the submission.
|
|
25
|
+
*/
|
|
26
|
+
const submitRequest = z.object({
|
|
27
|
+
form: z.string().min(1),
|
|
28
|
+
version: z.string().min(1).max(128),
|
|
29
|
+
/** Field name → submitted value. */
|
|
30
|
+
data: z
|
|
31
|
+
.record(z.string(), z.unknown())
|
|
32
|
+
.refine((data) => Object.keys(data).length <= 50, 'Too many fields'),
|
|
33
|
+
/**
|
|
34
|
+
* Honeypot. A real visitor never sees this input and so never fills it; a bot
|
|
35
|
+
* that fills every field it finds does. Named innocuously on purpose.
|
|
36
|
+
*/
|
|
37
|
+
website: z.string().optional()
|
|
38
|
+
});
|
|
39
|
+
/** Requests per window, per IP. Deliberately generous — this is a floor, not a filter. */
|
|
40
|
+
const IP_RATE_LIMIT = 10;
|
|
41
|
+
const FORM_RATE_LIMIT = 50;
|
|
42
|
+
const RATE_WINDOW_MS = 60_000;
|
|
43
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
44
|
+
/**
|
|
45
|
+
* In-memory rate limiting.
|
|
46
|
+
*
|
|
47
|
+
* Per-process and therefore per-instance: behind several replicas the effective
|
|
48
|
+
* limit multiplies, and a restart forgets everything. That is a real limitation
|
|
49
|
+
* and it is the right trade for a starter — it costs nothing, needs no Redis, and
|
|
50
|
+
* stops the naive case. A site under actual attack wants this at the edge (a WAF
|
|
51
|
+
* or the platform's own rate limiting), where a request can be dropped before it
|
|
52
|
+
* reaches an application process at all.
|
|
53
|
+
*/
|
|
54
|
+
const hits = new Map();
|
|
55
|
+
function rateLimited(key, limit) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const entry = hits.get(key);
|
|
58
|
+
if (!entry || now > entry.resetAt) {
|
|
59
|
+
hits.set(key, { count: 1, resetAt: now + RATE_WINDOW_MS });
|
|
60
|
+
// Opportunistic sweep: without it the map grows for the life of the process,
|
|
61
|
+
// one entry per IP ever seen.
|
|
62
|
+
if (hits.size > 5000) {
|
|
63
|
+
for (const [k, v] of hits)
|
|
64
|
+
if (now > v.resetAt)
|
|
65
|
+
hits.delete(k);
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
entry.count += 1;
|
|
70
|
+
return entry.count > limit;
|
|
71
|
+
}
|
|
72
|
+
/** Trusted connection address supplied by the framework bridge. */
|
|
73
|
+
function clientKey(c) {
|
|
74
|
+
return c.env.clientAddress?.trim() || 'unknown';
|
|
75
|
+
}
|
|
76
|
+
async function parseRequest(c) {
|
|
77
|
+
const contentLength = Number(c.req.header('content-length'));
|
|
78
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES)
|
|
79
|
+
return null;
|
|
80
|
+
const body = await c.req.text().catch(() => '');
|
|
81
|
+
if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES)
|
|
82
|
+
return null;
|
|
83
|
+
const json = (() => {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(body);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
const parsed = submitRequest.safeParse(json);
|
|
92
|
+
return parsed.success ? parsed.data : null;
|
|
93
|
+
}
|
|
94
|
+
export async function handleSubmit(c) {
|
|
95
|
+
const { aphexCMS } = c.var;
|
|
96
|
+
const parsed = await parseRequest(c);
|
|
97
|
+
if (!parsed) {
|
|
98
|
+
return c.json({ success: false, error: 'Invalid request' }, 400);
|
|
99
|
+
}
|
|
100
|
+
const { form: formId, version, data, website } = parsed;
|
|
101
|
+
/*
|
|
102
|
+
* A tripped honeypot returns success. Telling a bot it was detected teaches
|
|
103
|
+
* whoever wrote it to stop filling that field; a 200 with nothing stored costs
|
|
104
|
+
* them a submission and no information.
|
|
105
|
+
*/
|
|
106
|
+
if (website && website.trim() !== '') {
|
|
107
|
+
return c.json({ success: true });
|
|
108
|
+
}
|
|
109
|
+
if (rateLimited(`ip:${clientKey(c)}`, IP_RATE_LIMIT) ||
|
|
110
|
+
rateLimited(`form:${formId}`, FORM_RATE_LIMIT)) {
|
|
111
|
+
return c.json({ success: false, error: 'Too many submissions. Try again shortly.' }, 429);
|
|
112
|
+
}
|
|
113
|
+
const organizationId = await aphexCMS.databaseAdapter.resolvePublishedDocumentOrganizationId(formId, 'form');
|
|
114
|
+
if (!organizationId)
|
|
115
|
+
return c.json({ success: false, error: 'Form not found' }, 404);
|
|
116
|
+
const context = systemContext(organizationId);
|
|
117
|
+
/*
|
|
118
|
+
* Collections are reached by name, not through the typed `collections` map:
|
|
119
|
+
* that map is generated from the *app's* schemas, and a plugin's own
|
|
120
|
+
* collections aren't in it at compile time. `getCollection` is the accessor
|
|
121
|
+
* built for exactly this — dynamic access from route handlers.
|
|
122
|
+
*/
|
|
123
|
+
const forms = aphexCMS.localAPI.getCollection('form');
|
|
124
|
+
const submissions = aphexCMS.localAPI.getCollection('formSubmission');
|
|
125
|
+
if (!forms || !submissions) {
|
|
126
|
+
aphexCMS.logger?.error('[forms] plugin collections are not registered');
|
|
127
|
+
return c.json({ success: false, error: 'Forms are not configured' }, 500);
|
|
128
|
+
}
|
|
129
|
+
// Published only. A form still in draft is not live, and honouring it would let
|
|
130
|
+
// anyone submit against a form the editor hasn't finished.
|
|
131
|
+
const formDoc = await forms
|
|
132
|
+
.findByID(context, formId, { perspective: 'published' })
|
|
133
|
+
.catch(() => null);
|
|
134
|
+
if (!formDoc)
|
|
135
|
+
return c.json({ success: false, error: 'Form not found' }, 404);
|
|
136
|
+
if (formDoc._meta?.status !== 'published') {
|
|
137
|
+
return c.json({ success: false, error: 'Form not found' }, 404);
|
|
138
|
+
}
|
|
139
|
+
if (!formDoc._meta?.publishedHash || formDoc._meta.publishedHash !== version) {
|
|
140
|
+
return c.json({ success: false, error: 'This form changed. Refresh the page and try again.' }, 409);
|
|
141
|
+
}
|
|
142
|
+
const declared = (formDoc.fields ?? []);
|
|
143
|
+
const inputs = declared.filter(isInputField);
|
|
144
|
+
// Keep only what the form declares — see the note above about stuffing.
|
|
145
|
+
const submitted = {};
|
|
146
|
+
for (const field of inputs)
|
|
147
|
+
submitted[field.name] = data[field.name];
|
|
148
|
+
const definition = compileForm({ ...formDoc, id: formId });
|
|
149
|
+
const shaped = definition.transform ? definition.transform(submitted) : submitted;
|
|
150
|
+
const result = await validateFormData(definition, shaped);
|
|
151
|
+
if (!result.isValid) {
|
|
152
|
+
// Field-keyed so the form can show each message against its own input.
|
|
153
|
+
const errors = {};
|
|
154
|
+
for (const entry of result.errors)
|
|
155
|
+
errors[entry.field] = entry.errors[0] ?? 'Invalid';
|
|
156
|
+
return c.json({ success: false, error: 'Please check the form', errors }, 400);
|
|
157
|
+
}
|
|
158
|
+
const answers = inputs.map((field) => ({
|
|
159
|
+
field: field.name,
|
|
160
|
+
value: toStoredValue(result.normalizedData[field.name])
|
|
161
|
+
}));
|
|
162
|
+
// A one-line description for the submissions list, so it's readable without
|
|
163
|
+
// opening every row. The first answer with something in it is nearly always
|
|
164
|
+
// the name or the email.
|
|
165
|
+
const labels = fieldLabels(declared);
|
|
166
|
+
const first = answers.find((a) => a.value);
|
|
167
|
+
const summary = first
|
|
168
|
+
? `${labels.get(first.field) ?? first.field}: ${first.value}`
|
|
169
|
+
: 'Submission';
|
|
170
|
+
const submissionId = crypto.randomUUID();
|
|
171
|
+
const notificationEmails = (formDoc.emails ?? [])
|
|
172
|
+
.filter((email) => typeof email.to === 'string' && email.to.trim().length > 0)
|
|
173
|
+
.map((email, index) => ({
|
|
174
|
+
_type: 'submissionNotification',
|
|
175
|
+
_key: `notification-${index}`,
|
|
176
|
+
to: email.to?.trim(),
|
|
177
|
+
replyTo: email.replyTo?.trim() || undefined,
|
|
178
|
+
subject: email.subject ?? 'New form submission',
|
|
179
|
+
message: email.message ?? '{{allFields}}'
|
|
180
|
+
}));
|
|
181
|
+
await submissions.create(context, {
|
|
182
|
+
form: { _type: 'reference', _ref: formId },
|
|
183
|
+
summary: summary.slice(0, 200),
|
|
184
|
+
submittedAt: new Date().toISOString(),
|
|
185
|
+
submissionData: answers.map((answer, i) => ({
|
|
186
|
+
_type: 'answer',
|
|
187
|
+
_key: `a-${i}`,
|
|
188
|
+
...answer
|
|
189
|
+
})),
|
|
190
|
+
notificationEmails,
|
|
191
|
+
notificationFieldLabels: inputs.map((field, index) => ({
|
|
192
|
+
_type: 'submissionFieldLabel',
|
|
193
|
+
_key: `label-${index}`,
|
|
194
|
+
field: field.name,
|
|
195
|
+
label: labels.get(field.name) ?? field.name
|
|
196
|
+
}))
|
|
197
|
+
}, {
|
|
198
|
+
id: submissionId,
|
|
199
|
+
skipVersioning: true,
|
|
200
|
+
outboxEvents: [
|
|
201
|
+
{
|
|
202
|
+
type: formSubmissionCreated.type,
|
|
203
|
+
payload: formSubmissionCreated.parse({ submissionId, formId })
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
});
|
|
207
|
+
return c.json({
|
|
208
|
+
success: true,
|
|
209
|
+
confirmationType: formDoc.confirmationType ?? 'message',
|
|
210
|
+
redirectUrl: formDoc.confirmationType === 'redirect' ? (formDoc.redirectUrl ?? null) : null
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
//# sourceMappingURL=submit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"submit.js","sourceRoot":"","sources":["../src/lib/submit.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FormFieldValue } from './compile.js';
|
|
2
|
+
/**
|
|
3
|
+
* `{{fieldName}}` substitution for notification emails.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately tiny and deliberately not a template engine. The strings come
|
|
6
|
+
* from an editor, and the values come from the public internet; anything with
|
|
7
|
+
* expressions, property access or partials in it would be a way to reach further
|
|
8
|
+
* than the submission. A name is looked up in a plain map or it is left alone.
|
|
9
|
+
*/
|
|
10
|
+
/** One stored answer. */
|
|
11
|
+
export interface Answer {
|
|
12
|
+
field: string;
|
|
13
|
+
value: string;
|
|
14
|
+
}
|
|
15
|
+
/** The whole submission as `Label: value` lines, for the default `{{allFields}}`. */
|
|
16
|
+
export declare function renderAllFields(answers: Answer[], labels: Map<string, string>): string;
|
|
17
|
+
/**
|
|
18
|
+
* Replace `{{token}}` in an editor-authored string.
|
|
19
|
+
*
|
|
20
|
+
* An unknown token is left verbatim rather than blanked: a typo that renders as
|
|
21
|
+
* `{{emial}}` in the email is a bug someone notices and fixes, whereas one that
|
|
22
|
+
* silently renders nothing looks like the visitor left the field empty.
|
|
23
|
+
*/
|
|
24
|
+
export declare function renderTemplate(template: string, answers: Answer[], labels: Map<string, string>): string;
|
|
25
|
+
/** Field name → the label an editor gave it, for readable email output. */
|
|
26
|
+
export declare function fieldLabels(fields: FormFieldValue[]): Map<string, string>;
|
|
27
|
+
/**
|
|
28
|
+
* A submitted value as a string, for storage and email.
|
|
29
|
+
*
|
|
30
|
+
* Everything is stored as text on purpose: a submission is a record of what was
|
|
31
|
+
* sent, and it has to stay readable after the form is edited. A checkbox reads
|
|
32
|
+
* "Yes"/"No" rather than "true"/"false" because the audience for a submission is
|
|
33
|
+
* a person reading an inbox.
|
|
34
|
+
*/
|
|
35
|
+
export declare function toStoredValue(value: unknown): string;
|
|
36
|
+
//# sourceMappingURL=template.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template.d.ts","sourceRoot":"","sources":["../src/lib/template.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD;;;;;;;GAOG;AAEH,yBAAyB;AACzB,MAAM,WAAW,MAAM;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACd;AAKD,qFAAqF;AACrF,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAItF;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EAAE,EACjB,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GACzB,MAAM,CASR;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAQzE;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAIpD"}
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** `{{name}}` — a bare field name, nothing else. */
|
|
2
|
+
const TOKEN = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g;
|
|
3
|
+
/** The whole submission as `Label: value` lines, for the default `{{allFields}}`. */
|
|
4
|
+
export function renderAllFields(answers, labels) {
|
|
5
|
+
return answers
|
|
6
|
+
.map(({ field, value }) => `${labels.get(field) ?? field}: ${value || '—'}`)
|
|
7
|
+
.join('\n');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Replace `{{token}}` in an editor-authored string.
|
|
11
|
+
*
|
|
12
|
+
* An unknown token is left verbatim rather than blanked: a typo that renders as
|
|
13
|
+
* `{{emial}}` in the email is a bug someone notices and fixes, whereas one that
|
|
14
|
+
* silently renders nothing looks like the visitor left the field empty.
|
|
15
|
+
*/
|
|
16
|
+
export function renderTemplate(template, answers, labels) {
|
|
17
|
+
const values = new Map(answers.map((a) => [a.field, a.value]));
|
|
18
|
+
const all = renderAllFields(answers, labels);
|
|
19
|
+
return template.replace(TOKEN, (match, name) => {
|
|
20
|
+
if (name === 'allFields')
|
|
21
|
+
return all;
|
|
22
|
+
const value = values.get(name);
|
|
23
|
+
return value === undefined ? match : value;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Field name → the label an editor gave it, for readable email output. */
|
|
27
|
+
export function fieldLabels(fields) {
|
|
28
|
+
const labels = new Map();
|
|
29
|
+
for (const field of fields) {
|
|
30
|
+
if (typeof field.name === 'string' && field.name) {
|
|
31
|
+
labels.set(field.name, field.label ?? field.name);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return labels;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A submitted value as a string, for storage and email.
|
|
38
|
+
*
|
|
39
|
+
* Everything is stored as text on purpose: a submission is a record of what was
|
|
40
|
+
* sent, and it has to stay readable after the form is edited. A checkbox reads
|
|
41
|
+
* "Yes"/"No" rather than "true"/"false" because the audience for a submission is
|
|
42
|
+
* a person reading an inbox.
|
|
43
|
+
*/
|
|
44
|
+
export function toStoredValue(value) {
|
|
45
|
+
if (value === null || value === undefined)
|
|
46
|
+
return '';
|
|
47
|
+
if (typeof value === 'boolean')
|
|
48
|
+
return value ? 'Yes' : 'No';
|
|
49
|
+
return String(value);
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=template.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template.js","sourceRoot":"","sources":["../src/lib/template.ts"],"names":[],"mappings":"AAiBA,oDAAoD;AACpD,MAAM,KAAK,GAAG,wCAAwC,CAAC;AAEvD,qFAAqF;AACrF,MAAM,UAAU,eAAe,CAAC,OAAiB,EAAE,MAA2B;IAC7E,OAAO,OAAO;SACZ,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,KAAK,KAAK,KAAK,IAAI,GAAG,EAAE,CAAC;SAC3E,IAAI,CAAC,IAAI,CAAC,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAC7B,QAAgB,EAChB,OAAiB,EACjB,MAA2B;IAE3B,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC/D,MAAM,GAAG,GAAG,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;IAE7C,OAAO,QAAQ,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE,IAAY,EAAE,EAAE;QACtD,IAAI,IAAI,KAAK,WAAW;YAAE,OAAO,GAAG,CAAC;QACrC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC/B,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC;IAC5C,CAAC,CAAC,CAAC;AACJ,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,WAAW,CAAC,MAAwB;IACnD,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACzC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC5B,IAAI,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC;YAClD,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC;QACnD,CAAC;IACF,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAAC,KAAc;IAC3C,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAC;IACrD,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5D,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;AACtB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aphexcms/plugin-forms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Form builder plugin for AphexCMS — editor-composed forms, stored submissions, and email notifications",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/IcelandicIcecream/aphex.git",
|
|
12
|
+
"directory": "plugins/plugin-forms"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"svelte": "./dist/index.js",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./schema": {
|
|
24
|
+
"types": "./dist/schema.d.ts",
|
|
25
|
+
"svelte": "./dist/schema.js",
|
|
26
|
+
"default": "./dist/schema.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "tsc --watch --preserveWatchOutput",
|
|
34
|
+
"build": "tsc && node ../../scripts/fix-imports.js ./dist",
|
|
35
|
+
"check": "tsc --noEmit",
|
|
36
|
+
"dev:local": "node ../../scripts/swap-package-paths.js ./package.json src lib",
|
|
37
|
+
"prepack": "node ../../scripts/swap-package-paths.js ./package.json dist lib",
|
|
38
|
+
"postpack": "node ../../scripts/swap-package-paths.js ./package.json src lib"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"aphex",
|
|
42
|
+
"aphexcms",
|
|
43
|
+
"cms",
|
|
44
|
+
"plugin",
|
|
45
|
+
"forms",
|
|
46
|
+
"form-builder"
|
|
47
|
+
],
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"typescript": "^5.9.3",
|
|
50
|
+
"hono": "^4.6.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@aphexcms/cms-core": "workspace:>=9",
|
|
54
|
+
"zod": "^4.0.0"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@lucide/svelte": "^0.554.0"
|
|
58
|
+
}
|
|
59
|
+
}
|