create-nevela 0.7.1 → 0.9.1

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 (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. package/template/web/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import type { PublicForm } from "@/hooks/use-public-form";
5
+
6
+ /** The part of Cloudflare's script this uses. */
7
+ interface Turnstile {
8
+ render: (
9
+ element: HTMLElement,
10
+ options: { sitekey: string; action?: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void },
11
+ ) => string;
12
+ reset: (id: string) => void;
13
+ remove: (id: string) => void;
14
+ }
15
+
16
+ const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "";
17
+ const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
18
+
19
+ let loading: Promise<Turnstile> | null = null;
20
+
21
+ /** Cloudflare's script, asked for once however many forms a visit shows. */
22
+ function turnstile(): Promise<Turnstile> {
23
+ const loaded = () => (window as unknown as { turnstile?: Turnstile }).turnstile;
24
+ loading ??= new Promise<Turnstile>((resolve, reject) => {
25
+ const existing = loaded();
26
+ if (existing) return resolve(existing);
27
+ const script = document.createElement("script");
28
+ script.src = SCRIPT;
29
+ script.async = true;
30
+ script.defer = true;
31
+ script.onload = () => {
32
+ const ready = loaded();
33
+ if (ready) resolve(ready);
34
+ else reject(new Error("Turnstile didn't load."));
35
+ };
36
+ script.onerror = () => {
37
+ // Not kept: the next form to appear tries again.
38
+ loading = null;
39
+ script.remove();
40
+ reject(new Error("Turnstile didn't load."));
41
+ };
42
+ document.head.appendChild(script);
43
+ });
44
+ return loading;
45
+ }
46
+
47
+ /**
48
+ * What a protected form carries besides its own fields. Put it inside the form:
49
+ *
50
+ * ```tsx
51
+ * <PublicFormGuard form={form} />
52
+ * ```
53
+ *
54
+ * - The honeypot: a field a person never sees or reaches, and a bot fills in.
55
+ * - The robot check (Cloudflare Turnstile), where the descriptor asks for it and
56
+ * NEXT_PUBLIC_TURNSTILE_SITE_KEY is set. Its answer is good once, so it starts over
57
+ * after every attempt. With no site key there is no widget, and the form carries the
58
+ * honeypot and its token only.
59
+ *
60
+ * The form's token is the hook's to send. Nothing here is the check itself: Laravel
61
+ * makes every one of them.
62
+ */
63
+ export function PublicFormGuard({ form, className }: { form: PublicForm; className?: string }) {
64
+ const { honeypot, turnstile: asked, action, attempt, robot } = form.guard;
65
+ const holder = React.useRef<HTMLDivElement>(null);
66
+ const widget = React.useRef<string | null>(null);
67
+ const shown = asked && SITE_KEY !== "";
68
+
69
+ // Rendered by hand, not by the script on load: moving between pages here doesn't reload
70
+ // the document, so a widget the script found once would not be found again.
71
+ React.useEffect(() => {
72
+ if (!shown || !holder.current) return;
73
+ let current = true;
74
+ let api: Turnstile | null = null;
75
+ turnstile()
76
+ .then((ready) => {
77
+ if (!current || !holder.current) return;
78
+ api = ready;
79
+ widget.current = ready.render(holder.current, {
80
+ sitekey: SITE_KEY,
81
+ // Which form this check is for. Laravel refuses a token solved for another.
82
+ ...(action ? { action } : {}),
83
+ callback: (token) => robot(token),
84
+ "expired-callback": () => robot(null),
85
+ "error-callback": () => robot(null),
86
+ });
87
+ })
88
+ .catch(() => robot(null));
89
+ return () => {
90
+ current = false;
91
+ if (api && widget.current) api.remove(widget.current);
92
+ widget.current = null;
93
+ robot(null);
94
+ };
95
+ }, [shown, action, robot]);
96
+
97
+ // A used answer is refused the second time, so a corrected form needs a new one.
98
+ React.useEffect(() => {
99
+ if (attempt === 0 || !widget.current) return;
100
+ const api = (window as unknown as { turnstile?: Turnstile }).turnstile;
101
+ api?.reset(widget.current);
102
+ }, [attempt]);
103
+
104
+ React.useEffect(() => {
105
+ if (asked && SITE_KEY === "" && process.env.NODE_ENV !== "production") {
106
+ console.warn("This form asks for the robot check, and NEXT_PUBLIC_TURNSTILE_SITE_KEY isn't set, so no widget is shown and Laravel will refuse it.");
107
+ }
108
+ }, [asked]);
109
+
110
+ return (
111
+ <>
112
+ {honeypot && (
113
+ // Off the page, and out of the way of a keyboard and a screen reader. Not `hidden`
114
+ // or display:none: a bot that skips those would skip this too.
115
+ <div aria-hidden="true" style={{ position: "absolute", left: "-10000px", top: "auto", width: 1, height: 1, overflow: "hidden" }}>
116
+ <label>
117
+ Leave this empty
118
+ <input type="text" name={honeypot} tabIndex={-1} autoComplete="off" defaultValue="" />
119
+ </label>
120
+ </div>
121
+ )}
122
+ {shown && <div ref={holder} className={className} />}
123
+ </>
124
+ );
125
+ }
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ /** What Laravel hands a protected form before it is shown: GET /api/public/_nevela/form/<slug>. */
6
+ interface Issued {
7
+ token: string | null;
8
+ honeypot: string | null;
9
+ minSeconds: number;
10
+ turnstile: boolean;
11
+ /** The name the robot check is made under. Cloudflare returns it, and Laravel checks it is this form's. */
12
+ action?: string | null;
13
+ }
14
+
15
+ export interface PublicForm {
16
+ /** Give this to the form: `<form onSubmit={form.submit}>`. */
17
+ submit: (event: React.FormEvent<HTMLFormElement>) => void;
18
+ /** Being sent. */
19
+ pending: boolean;
20
+ /** Sent, and taken. */
21
+ sent: boolean;
22
+ /** What went wrong, where it isn't about one field: the robot check, too many tries, no connection. */
23
+ error: string | null;
24
+ /** What is wrong with a field, by its name. */
25
+ errors: Record<string, string>;
26
+ /** Start again after a form was sent, to send another. */
27
+ again: () => void;
28
+ /** For `<PublicFormGuard form={form} />`. Not for the form itself to read. */
29
+ guard: {
30
+ honeypot: string | null;
31
+ turnstile: boolean;
32
+ action: string | null;
33
+ /** Goes up after every attempt: a robot token is good once, so the widget starts over. */
34
+ attempt: number;
35
+ robot: (token: string | null) => void;
36
+ };
37
+ }
38
+
39
+ export interface PublicFormOptions {
40
+ /** The record to send, from the form's fields. By default each named field, as typed, with empty ones left out. */
41
+ values?: (data: FormData) => Record<string, unknown>;
42
+ onSent?: () => void;
43
+ }
44
+
45
+ const UNREACHABLE = "That didn't send. Check your connection and try again.";
46
+
47
+ /**
48
+ * A form anyone can send: a resource's public create, with whatever its descriptor's
49
+ * "protect" asks for.
50
+ *
51
+ * ```tsx
52
+ * const form = usePublicForm("contact-messages");
53
+ *
54
+ * <form onSubmit={form.submit}>
55
+ * <input name="email" />
56
+ * {form.errors.email}
57
+ * <PublicFormGuard form={form} />
58
+ * <button disabled={form.pending}>Send</button>
59
+ * {form.error}
60
+ * </form>
61
+ * ```
62
+ *
63
+ * It asks Laravel for the form's token when the form is shown and again after every
64
+ * attempt, sends the fields with it, and sorts the answer: a problem with a field goes to
65
+ * `errors` under that field's name, and anything else to `error`. The checks themselves
66
+ * are Laravel's. Nothing here decides who is let through.
67
+ *
68
+ * A resource with no "protect" works too: there is no token to fetch, and the form is
69
+ * sent as it is.
70
+ */
71
+ export function usePublicForm(slug: string, { values, onSent }: PublicFormOptions = {}): PublicForm {
72
+ const [issued, setIssued] = React.useState<Issued | null>(null);
73
+ const [attempt, setAttempt] = React.useState(0);
74
+ const [pending, setPending] = React.useState(false);
75
+ const [sent, setSent] = React.useState(false);
76
+ const [error, setError] = React.useState<string | null>(null);
77
+ const [errors, setErrors] = React.useState<Record<string, string>>({});
78
+ const robot = React.useRef<string | null>(null);
79
+ const sending = React.useRef(false);
80
+
81
+ // The token says when the form was handed out, so it is asked for when the form
82
+ // appears, and a fresh one after each attempt.
83
+ React.useEffect(() => {
84
+ let current = true;
85
+ fetch(`/api/public/_nevela/form/${encodeURIComponent(slug)}`, { cache: "no-store", headers: { Accept: "application/json" } })
86
+ .then((response) => (response.ok ? (response.json() as Promise<Issued>) : null))
87
+ .then((form) => {
88
+ if (current) setIssued(form);
89
+ })
90
+ .catch(() => {
91
+ if (current) setIssued(null);
92
+ });
93
+ return () => {
94
+ current = false;
95
+ };
96
+ }, [slug, attempt]);
97
+
98
+ const setRobot = React.useCallback((token: string | null) => {
99
+ robot.current = token;
100
+ }, []);
101
+
102
+ const submit = React.useCallback(
103
+ (event: React.FormEvent<HTMLFormElement>) => {
104
+ event.preventDefault();
105
+ if (sending.current) return;
106
+ const element = event.currentTarget;
107
+ const data = new FormData(element);
108
+ const body: Record<string, unknown> = values ? { ...values(data) } : {};
109
+ if (!values) {
110
+ for (const [name, value] of data.entries()) {
111
+ // A file is uploaded on its own, and its key sent here: see the public uploads route.
112
+ if (typeof value === "string" && value !== "") body[name] = value;
113
+ }
114
+ }
115
+ // The field no person sees goes as it is, whatever `values` kept: it is how a bot is known.
116
+ if (issued?.honeypot) {
117
+ const bait = data.get(issued.honeypot);
118
+ if (typeof bait === "string" && bait !== "") body[issued.honeypot] = bait;
119
+ }
120
+ if (issued?.token) body._form = issued.token;
121
+ if (robot.current) body._turnstile = robot.current;
122
+
123
+ sending.current = true;
124
+ setPending(true);
125
+ setError(null);
126
+ setErrors({});
127
+ fetch(`/api/public/${encodeURIComponent(slug)}`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
130
+ body: JSON.stringify(body),
131
+ })
132
+ .then(async (response) => {
133
+ if (response.ok) {
134
+ element.reset();
135
+ setSent(true);
136
+ onSent?.();
137
+ return;
138
+ }
139
+ const answer = (await response.json().catch(() => null)) as { error?: string; issues?: { path?: string; message?: string }[] } | null;
140
+ const byField: Record<string, string> = {};
141
+ const rest: string[] = [];
142
+ for (const issue of answer?.issues ?? []) {
143
+ if (!issue.message) continue;
144
+ // The first thing wrong with a field is the one to fix first.
145
+ if (issue.path && element.elements.namedItem(issue.path)) byField[issue.path] ??= issue.message;
146
+ else rest.push(issue.path ? `${issue.path}: ${issue.message}` : issue.message);
147
+ }
148
+ setErrors(byField);
149
+ setError(Object.keys(byField).length > 0 && rest.length === 0 ? null : (rest[0] ?? answer?.error ?? UNREACHABLE));
150
+ })
151
+ .catch(() => setError(UNREACHABLE))
152
+ .finally(() => {
153
+ sending.current = false;
154
+ robot.current = null;
155
+ setPending(false);
156
+ // Whatever happened, the next attempt needs a new token and a new robot check.
157
+ setAttempt((count) => count + 1);
158
+ });
159
+ },
160
+ [slug, issued, values, onSent],
161
+ );
162
+
163
+ const again = React.useCallback(() => {
164
+ setSent(false);
165
+ setError(null);
166
+ setErrors({});
167
+ }, []);
168
+
169
+ const guard = React.useMemo(
170
+ () => ({ honeypot: issued?.honeypot ?? null, turnstile: issued?.turnstile ?? false, action: issued?.action ?? null, attempt, robot: setRobot }),
171
+ [issued, attempt, setRobot],
172
+ );
173
+
174
+ return { submit, pending, sent, error, errors, again, guard };
175
+ }
@@ -51,8 +51,10 @@ export interface PermissionFeature {
51
51
  /** The first half of a permission: "products". */
52
52
  key: string;
53
53
  name: string;
54
- /** The second halves that mean something for it: create, view, edit, delete. */
54
+ /** The second halves that mean something for it: create, view, edit, delete, then the resource's own actions. */
55
55
  actions: string[];
56
+ /** What a resource's own actions are called, by second half: { accept: "Accept" }. */
57
+ labels?: Record<string, string>;
56
58
  }
57
59
 
58
60
  export interface PermissionModule {
@@ -1,3 +1,4 @@
1
+ import { actionsOf, type ResourceAction } from "@/lib/extras";
1
2
  import { cache } from "react";
2
3
  import { notFound, redirect } from "next/navigation";
3
4
  import { can, type Policy, type PolicyAction, type Resource } from "@flaredev/core";
@@ -96,6 +97,15 @@ export async function adminPermissions(resourceName: string): Promise<Record<Exc
96
97
  return { read: read!, create: create!, update: update!, delete: remove! };
97
98
  }
98
99
 
100
+ /**
101
+ * The resource's own actions that the current person may run, for drawing their buttons.
102
+ * Each is a permission by name. Laravel checks it again when the button is pressed.
103
+ */
104
+ export async function allowedActions(resourceName: string): Promise<ResourceAction[]> {
105
+ const { session, may } = await dashboardSession();
106
+ return session ? actionsOf(resourceName).filter((action) => may(action.permission)) : [];
107
+ }
108
+
99
109
  const stores = new Map<string, ResourceStore>();
100
110
 
101
111
  /** CRUD store for a resource by name (404 for unknown names). */
@@ -0,0 +1,105 @@
1
+ // What a resource has beyond its fields, which Flare's descriptor has no place for.
2
+ //
3
+ // Written by `php nevela generate` into resources/index.ts as `extras`, by resource
4
+ // name, from the descriptors in the Laravel app. Read through here, on the server: a
5
+ // client component is handed what it needs of it as props.
6
+
7
+ import * as registry from "@/resources";
8
+
9
+ /** A field an action asks for, as the descriptor has it. */
10
+ export interface ActionField {
11
+ kind: "string" | "text" | "int" | "float" | "boolean" | "date" | "datetime" | "enum";
12
+ format?: string;
13
+ options?: string[];
14
+ required?: boolean;
15
+ label?: string;
16
+ maxLength?: number;
17
+ }
18
+
19
+ /**
20
+ * Something that can be done to a record, or to the list, beyond creating, editing and
21
+ * deleting: the descriptor's "actions" section. What it does is a class in the Laravel
22
+ * app; this is what the dashboard needs to offer it.
23
+ */
24
+ export interface ResourceAction {
25
+ /** Its name in the descriptor and in the address: "accept". */
26
+ key: string;
27
+ /** The permission that allows it: "internship_applications.accept". */
28
+ permission: string;
29
+ label: string;
30
+ /** A lucide icon name. */
31
+ icon?: string;
32
+ /** Where its button is: on one record, or on the list as a whole. */
33
+ on: "record" | "collection";
34
+ /** A question asked before it runs. */
35
+ confirm?: string;
36
+ /** Field values a record must have for the action to apply to it. */
37
+ when?: Record<string, string | number | boolean | null>;
38
+ /** What it asks for, in a sheet. */
39
+ fields?: Record<string, ActionField>;
40
+ /** A record action that can also be run on the rows selected in the table. */
41
+ many?: boolean;
42
+ }
43
+
44
+ interface ResourceExtras {
45
+ actions?: Record<string, Omit<ResourceAction, "key">>;
46
+ /** The date-and-time field its records are published by (the descriptor's "publish"). */
47
+ publish?: string;
48
+ /** Where on the site its public records are a blog: "/blog". */
49
+ blog?: string;
50
+ /** What its public form checks, and the address it has proved (the descriptor's "protect" and "confirm"). */
51
+ form?: ResourceForm;
52
+ }
53
+
54
+ /** A resource's public form, as far as the dashboard needs to know of it. */
55
+ export interface ResourceForm {
56
+ protect?: { turnstile?: boolean; honeypot?: boolean; minSeconds?: number; email?: string };
57
+ /** The field holding an address that is proved by a link sent to it. Until then a record is "Not confirmed". */
58
+ confirm?: string;
59
+ }
60
+
61
+ /** What a record with a publish date is: on the site, waiting for its time, or neither. */
62
+ export type PublishState = "published" | "scheduled" | "draft";
63
+
64
+ export const PUBLISH_LABELS: Record<PublishState, string> = { published: "Published", scheduled: "Scheduled", draft: "Draft" };
65
+
66
+ /** An app generated before there were any has no `extras` in its registry, and none to have. */
67
+ const extras = ((registry as { extras?: unknown }).extras ?? {}) as Record<string, ResourceExtras>;
68
+
69
+ /** A resource's actions, in the order the descriptor gives them. */
70
+ export function actionsOf(resourceName: string): ResourceAction[] {
71
+ return Object.entries(extras[resourceName]?.actions ?? {}).map(([key, action]) => ({ ...action, key }));
72
+ }
73
+
74
+ /** The field a resource's records are published by, if it has one. */
75
+ export function publishFieldOf(resourceName: string): string | undefined {
76
+ return extras[resourceName]?.publish;
77
+ }
78
+
79
+ /** What a resource's public form checks and confirms, if its descriptor says either. */
80
+ export function formOf(resourceName: string): ResourceForm | undefined {
81
+ return extras[resourceName]?.form;
82
+ }
83
+
84
+ /** Which of the three a record is, from its publish date and the time now. */
85
+ export function publishState(value: unknown, now: Date = new Date()): PublishState {
86
+ if (typeof value !== "string" || value === "") return "draft";
87
+ return new Date(value).getTime() <= now.getTime() ? "published" : "scheduled";
88
+ }
89
+
90
+ /** Links for the site's header to what the resources have put on the site: the blog. */
91
+ export function siteLinks(): { label: string; href: string }[] {
92
+ return Object.values(extras).flatMap((entry) => (entry.blog ? [{ label: "Blog", href: entry.blog }] : []));
93
+ }
94
+
95
+ /** Whether an action applies to a record as it is now. Laravel asks the same before running it. */
96
+ export function actionApplies(action: ResourceAction, record: Record<string, unknown>): boolean {
97
+ return Object.entries(action.when ?? {}).every(([field, wanted]) => {
98
+ const value = record[field] ?? null;
99
+ if (wanted === null) return value === null;
100
+ if (value === null) return false;
101
+ // A yes/no value may arrive as 1 or 0.
102
+ if (typeof wanted === "boolean") return (value === true || value === 1 || value === "1") === wanted;
103
+ return String(value) === String(wanted);
104
+ });
105
+ }
@@ -17,5 +17,19 @@ export function fileUrl(key: string, rendition?: string): string {
17
17
  return `/api/_nevela/files/${path.split("/").map(encodeURIComponent).join("/")}`;
18
18
  }
19
19
 
20
+ /**
21
+ * Whether a key is a private file's. Such a file has no address of its own: asked for by
22
+ * key, Laravel answers that there is no such file. It is fetched through its record.
23
+ */
24
+ export const isPrivateKey = (key: string) => key.startsWith("private/");
25
+
26
+ /**
27
+ * Where a private file is fetched from: the record it belongs to and the field it is in.
28
+ * Laravel hands it over only to someone signed in who may view that record.
29
+ */
30
+ export function privateFileUrl(resourceSlug: string, id: string, fieldKey: string): string {
31
+ return `/api/_nevela/private/${encodeURIComponent(resourceSlug)}/${encodeURIComponent(id)}/${encodeURIComponent(fieldKey)}`;
32
+ }
33
+
20
34
  /** "products/image/2026/10/<uuid>-kettle.webp" → "kettle.webp" */
21
35
  export const fileName = (key: string) => key.slice(key.lastIndexOf("/") + 1).replace(/^[0-9a-f-]{36}-/, "");
@@ -34,6 +34,17 @@ export async function callerHeaders(): Promise<Record<string, string>> {
34
34
  if (agent) sent["X-Nevela-User-Agent"] = agent.slice(0, 500);
35
35
  const address = (incoming.get("x-forwarded-for") ?? "").split(",")[0]?.trim() || incoming.get("x-real-ip");
36
36
  if (address) sent["X-Nevela-Ip"] = address.replace(/^::ffff:/, "");
37
+ // That one is for showing: the first entry of X-Forwarded-For is whatever the request
38
+ // came with, and anyone can send one. The address a visitor is *counted* by (the public
39
+ // routes' throttle) has to be one they couldn't have made up. Each proxy adds the
40
+ // address it was called from to the end of the list, so with one proxy in front of this
41
+ // app the last entry is the visitor as that proxy saw them. NEVELA_TRUSTED_PROXIES says
42
+ // how many proxies are in front: 1 unless set, and 0 when this app is reached directly,
43
+ // in which case nobody's word is taken and visitors are counted together.
44
+ const proxies = Number.parseInt(process.env.NEVELA_TRUSTED_PROXIES ?? "1", 10);
45
+ const chain = (incoming.get("x-forwarded-for") ?? "").split(",").map((part) => part.trim()).filter(Boolean);
46
+ const counted = Number.isInteger(proxies) && proxies > 0 ? chain[chain.length - proxies] : undefined;
47
+ sent["X-Nevela-Client"] = counted ? counted.replace(/^::ffff:/, "") : "unknown";
37
48
  // Proves to Laravel that this is the dashboard speaking for a browser, and not someone
38
49
  // calling the API with made-up headers. The same value is in the Laravel app's .env.
39
50
  if (process.env.NEVELA_PROXY_SECRET) sent["X-Nevela-Proxy-Secret"] = process.env.NEVELA_PROXY_SECRET;
@@ -80,3 +91,52 @@ export async function laravel(path: string, { method = "GET", body, token }: Lar
80
91
  }
81
92
  return { status: response.status, ok: response.ok, body: parsed };
82
93
  }
94
+
95
+ export interface PublicRequest {
96
+ /** How long an answer may be reused, in seconds. Default 300. `false` keeps it until one of its tags is revalidated. */
97
+ revalidate?: number | false;
98
+ /** What makes it stale. `resourceTag("Post")` from lib/cache: the dashboard revalidates it when a post is created, changed, deleted or restored. */
99
+ tags?: string[];
100
+ }
101
+
102
+ /**
103
+ * Read what a resource's "public" section opens, for a page anyone can visit.
104
+ *
105
+ * ```ts
106
+ * const { body } = await laravelPublic("posts?sort=-publishedAt", { tags: [resourceTag("Post")] });
107
+ * ```
108
+ *
109
+ * Unlike `laravel()` it carries nothing of the person asking: no token and none of their
110
+ * headers. The answer is the same for everyone, so it is cached and shared between
111
+ * visitors, and a published post appears as soon as the dashboard saves it, because
112
+ * saving revalidates the resource's tag.
113
+ *
114
+ * `path` is under `{api}/public/`. To send a form (a public create), post to
115
+ * `/api/public/<slug>` from the browser: see app/api/[...path]/route.ts.
116
+ */
117
+ export async function laravelPublic(path: string, { revalidate = 300, tags = [] }: PublicRequest = {}): Promise<LaravelResponse> {
118
+ let response: Response;
119
+ try {
120
+ response = await fetch(`${API_URL}/public/${path.replace(/^\/+/, "")}`, {
121
+ headers: {
122
+ Accept: "application/json",
123
+ // The site reading its own data, to show to many: Laravel doesn't count it against
124
+ // a visitor's allowance, which it otherwise would against this server's address.
125
+ ...(process.env.NEVELA_PROXY_SECRET ? { "X-Nevela-Proxy-Secret": process.env.NEVELA_PROXY_SECRET } : {}),
126
+ },
127
+ next: { revalidate, tags },
128
+ });
129
+ } catch {
130
+ return { status: 502, ok: false, body: { error: "Can't reach the API." } };
131
+ }
132
+ const text = response.status === 204 ? "" : await response.text();
133
+ let parsed: unknown = null;
134
+ if (text) {
135
+ try {
136
+ parsed = JSON.parse(text);
137
+ } catch {
138
+ parsed = { error: `Laravel answered ${response.status} with something that isn't JSON.` };
139
+ }
140
+ }
141
+ return { status: response.status, ok: response.ok, body: parsed };
142
+ }
@@ -0,0 +1,79 @@
1
+ // Rich text: a text field whose value is HTML, written in an editor.
2
+ //
3
+ // Laravel is what makes it safe. Whatever a form, a script or a file sends is cleaned
4
+ // there against a list of tags (config/nevela.php, rich_text) before it is stored, and
5
+ // again when it is read, so the HTML that reaches this app is HTML that may be shown.
6
+ // Nothing here is a security boundary; tidy() below only keeps the editor neat.
7
+
8
+ /** The tags the editor's toolbar makes, which are the ones Laravel keeps by default. */
9
+ const KEPT = new Set(["p", "br", "strong", "em", "s", "a", "h2", "h3", "ul", "ol", "li", "blockquote"]);
10
+ const SAME_AS: Record<string, string> = { b: "strong", i: "em", strike: "s", del: "s" };
11
+ const DROPPED = new Set(["script", "style", "iframe", "object", "embed", "noscript", "template", "svg", "math", "head", "title", "meta", "link"]);
12
+
13
+ /**
14
+ * Whether a field holds rich text. The generated descriptor says so with `rich: true`
15
+ * on a text field, a key Flare doesn't know and carries along untouched.
16
+ */
17
+ export function isRich(def: { kind: string }): boolean {
18
+ return def.kind === "text" && (def as { rich?: boolean }).rich === true;
19
+ }
20
+
21
+ /** The words alone, on one line: what a table cell shows of a rich text value. */
22
+ export function richToText(html: string, limit = 0): string {
23
+ const text = html
24
+ .replace(/<(br|\/p|\/h[1-6]|\/li|\/blockquote)\b[^>]*>/gi, " ")
25
+ .replace(/<[^>]*>/g, "")
26
+ .replace(/&nbsp;/g, " ")
27
+ .replace(/&lt;/g, "<")
28
+ .replace(/&gt;/g, ">")
29
+ .replace(/&quot;/g, '"')
30
+ .replace(/&#0*39;|&apos;/g, "'")
31
+ .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
32
+ .replace(/&amp;/g, "&")
33
+ .replace(/\s+/g, " ")
34
+ .trim();
35
+ return limit > 0 && text.length > limit ? `${text.slice(0, limit - 1).trimEnd()}…` : text;
36
+ }
37
+
38
+ /** Whether there is anything in it to read. An editor that was opened and left holds `<p><br></p>`. */
39
+ export function isBlankHtml(html: string): boolean {
40
+ return richToText(html) === "";
41
+ }
42
+
43
+ /**
44
+ * What was pasted, with only what the toolbar could have made: a page copied from a
45
+ * word processor arrives with fonts, colours and spans that would be stripped on save
46
+ * anyway, and look wrong until then. Browser only.
47
+ */
48
+ export function tidyHtml(html: string): string {
49
+ const body = new DOMParser().parseFromString(html, "text/html").body;
50
+ const escape = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
51
+ const write = (node: Node): string => {
52
+ if (node.nodeType === Node.TEXT_NODE) return escape(node.textContent ?? "");
53
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
54
+ const element = node as Element;
55
+ const raw = element.tagName.toLowerCase();
56
+ const tag = SAME_AS[raw] ?? raw;
57
+ if (DROPPED.has(tag)) return "";
58
+ const inner = Array.from(element.childNodes).map(write).join("");
59
+ if (!KEPT.has(tag)) return inner;
60
+ if (tag === "br") return "<br>";
61
+ if (tag === "a") {
62
+ const href = element.getAttribute("href") ?? "";
63
+ return /^(https?:|mailto:|\/|#)/i.test(href.trim()) ? `<a href="${escape(href.trim()).replace(/"/g, "&quot;")}">${inner}</a>` : inner;
64
+ }
65
+ return `<${tag}>${inner}</${tag}>`;
66
+ };
67
+ return Array.from(body.childNodes).map(write).join("");
68
+ }
69
+
70
+ /** An address as it was typed, made into one a link can have. Empty when it can't be one. */
71
+ export function linkAddress(typed: string): string {
72
+ const address = typed.trim();
73
+ if (address === "") return "";
74
+ if (/^(https?:\/\/|mailto:|\/|#)/i.test(address)) return address;
75
+ // Any other scheme (javascript:, data:) is not somewhere a link goes.
76
+ if (/^[a-z][a-z0-9+.-]*:/i.test(address) && !/^[^/:]+:\d/.test(address)) return "";
77
+ if (/^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/.test(address)) return `mailto:${address}`;
78
+ return `https://${address}`;
79
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.7.1",
3
+ "version": "0.9.1",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {