@levo-so/core 0.1.76 → 0.1.79

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 ADDED
@@ -0,0 +1,263 @@
1
+ # @levo-so/core
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@levo-so/core)](https://www.npmjs.com/package/@levo-so/core)
4
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue)](https://www.typescriptlang.org/)
5
+ [![ESM only](https://img.shields.io/badge/ESM-only-brightgreen)](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)
6
+
7
+ The foundational JavaScript/TypeScript client for the [Levo](https://levo.so) platform. Every Levo-powered application — whether a website, web app, or backend service — starts here. It handles authentication, dynamic form collections, blog content, media, and all communication with the Levo API.
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ - [Installation](#installation)
14
+ - [Concepts](#concepts)
15
+ - [Setup](#setup)
16
+ - [Modules](#modules)
17
+ - [Membership](#membership)
18
+ - [Collection](#collection)
19
+ - [Blog](#blog)
20
+ - [Media](#media)
21
+ - [Error Handling](#error-handling)
22
+ - [Querying & Filtering](#querying--filtering)
23
+ - [Utilities](#utilities)
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ # pnpm
31
+ pnpm add @levo-so/core
32
+
33
+ # npm
34
+ npm install @levo-so/core
35
+
36
+ # bun
37
+ bun add @levo-so/core
38
+ ```
39
+
40
+ > ESM only. Requires a bundler or Node 18+.
41
+
42
+ ---
43
+
44
+ ## Concepts
45
+
46
+ Before jumping into code, a few Levo concepts worth understanding:
47
+
48
+ **Workspace** — Everything in Levo lives inside a workspace. Think of it as your project or organization account. Your workspace ID is visible in the Levo dashboard under Settings, and looks like `W1A2B3C4` (a `W` followed by 7 uppercase characters). All API requests are scoped to this workspace.
49
+
50
+ **Collections** — Levo's dynamic form and content system. A collection is a schema — it defines fields, their types, validation rules, and how they should be rendered (the widget). You submit data into a collection, and can query that data back. They power everything from contact forms to complex multi-step data entry flows.
51
+
52
+ **Membership** — Levo's built-in user identity and authentication system. It supports multiple sign-in methods (OAuth, magic links, OTP, passwords) and manages sessions natively, so you don't need a separate auth provider.
53
+
54
+ **APP_MODE vs NODE_ENV** — These are distinct. `NODE_ENV` is the standard build-time environment (`"development"` or `"production"`) and affects internal behavior like log verbosity and whether errors are shipped to Levo APM. `APP_MODE` is a Levo-specific concept for your deployment context — for example, `"production"`, `"staging"`, or `"preview"`. A staging server often runs with `NODE_ENV=production` (optimized build) but `APP_MODE=staging` (staging data, restricted features). `@levo-so/core` stores it but doesn't act on it — it's available via `client.APP_MODE` for your application logic.
55
+
56
+ ---
57
+
58
+ ## Setup
59
+
60
+ Create a single client instance and share it across your application:
61
+
62
+ ```typescript
63
+ import { createLevoClient } from "@levo-so/core";
64
+
65
+ const client = createLevoClient({
66
+ workspace: "W1234ABC", // Your workspace ID from the Levo dashboard
67
+ apiUrl: "https://public-api.levo.so", // Levo's public API — use as-is unless self-hosting
68
+ NODE_ENV: process.env.NODE_ENV,
69
+ APP_MODE: process.env.APP_MODE, // e.g. "production", "staging", "preview"
70
+ });
71
+ ```
72
+
73
+ In a browser context, the client automatically reads the current page URL, title, and referrer and attaches them to every request — this is used for analytics and debugging in the Levo dashboard. You don't need to configure this manually.
74
+
75
+ For SPAs where the URL changes without a full page load, call `client.updatePageContext({ url, title })` on navigation.
76
+
77
+ ---
78
+
79
+ ## Modules
80
+
81
+ ### Membership
82
+
83
+ Levo handles identity for your users. The membership module covers the full lifecycle: signing up, signing in through any method, managing the session, and updating profile data. Sessions are cookie-based and managed by Levo automatically.
84
+
85
+ **Sign-in methods available:**
86
+
87
+ - **OAuth** — Redirect users to Google, LinkedIn, or Microsoft. Call `getOAuthURL()` to get the provider-specific redirect URL, then handle the callback on your end.
88
+ - **Magic link** — Send a one-click login email with `requestMagicLink()`. The user clicks the link and lands on your callback URL.
89
+ - **OTP** — Send a one-time code via email or WhatsApp with `requestOtp()`, then verify it with `signInWithOtp()`.
90
+ - **Password** — Traditional email + password auth via `signInWithPassword()` and `signUpWithPassword()`.
91
+
92
+ ```typescript
93
+ // Get OAuth redirect URLs for all providers
94
+ const urls = await client.membership.getOAuthURL();
95
+ window.location.href = urls.google;
96
+
97
+ // OTP flow
98
+ await client.membership.requestOtp({ email: "user@example.com", type: "email" });
99
+ const account = await client.membership.signInWithOtp({ email, otp: "123456" });
100
+
101
+ // Password flow
102
+ const account = await client.membership.signInWithPassword({ email, password });
103
+
104
+ // Check current session — returns null if not signed in
105
+ const me = await client.membership.getMe();
106
+
107
+ // Update profile
108
+ await client.membership.updateMe({ first_name: "Jane", username: "jane" });
109
+
110
+ await client.membership.signOut();
111
+ ```
112
+
113
+ ---
114
+
115
+ ### Collection
116
+
117
+ A collection is a schema — it tells you what fields exist, what type they are, and how they should be presented (the widget). Your application uses this schema to render forms, validate input, and submit data. Levo stores the submissions and makes them queryable.
118
+
119
+ Collections also support a **draft workflow**: save in-progress data before the user is ready to submit, let them return and continue, then finalize with a submit call. This is useful for multi-step forms or forms where users might leave and come back.
120
+
121
+ ```typescript
122
+ // Fetch the schema for a collection — use this to render your form
123
+ const { content } = await client.collection.getAllCollections();
124
+ const schema = content.data.find(c => c.key === "my-form");
125
+
126
+ // Submit completed form data
127
+ await client.collection.saveCollectionData({
128
+ collection_id: schema.id,
129
+ data: { name: "Alice", email: "alice@example.com", message: "Hello" },
130
+ });
131
+
132
+ // Query existing submissions (e.g. to show a user their past entries)
133
+ const { content } = await client.collection.getCollectionContentList({
134
+ collection_key: "my-form",
135
+ page: 1,
136
+ limit: 20,
137
+ where: { status: { equals: "approved" } },
138
+ });
139
+
140
+ // Draft → submit (for multi-step or save-and-continue flows)
141
+ const draft = await client.collection.saveDraftEntry("my-form", partialData);
142
+ await client.collection.editDraftEntry("my-form", draft.content.data.id, moreData);
143
+ await client.collection.submitDraftEntry("my-form", { id: draft.content.data.id });
144
+ ```
145
+
146
+ ---
147
+
148
+ ### Blog
149
+
150
+ Pull blog content from any Levo-hosted blog into your application. Posts come back with everything you'd expect: HTML and structured JSON content, cover images (with responsive variants at multiple widths), reading time, authors, tags, categories, and full SEO metadata.
151
+
152
+ The blog key is the identifier for your specific blog, visible in the Levo dashboard.
153
+
154
+ ```typescript
155
+ // All published posts, newest first
156
+ const { content } = await client.blog.getAllBlogs("my-blog-key", {
157
+ page: 1,
158
+ limit: 10,
159
+ sort: { published_at: "desc" },
160
+ where: { status: { equals: "published" } },
161
+ });
162
+ // content.data → IPost[]
163
+ // content.meta.total → total count for pagination
164
+
165
+ // A single post by its slug
166
+ const { content } = await client.blog.getSingleBlog("my-blog-key", "my-post-slug");
167
+ // content.data → IPost (includes content.html, content.json, cover_image, og_image, etc.)
168
+ ```
169
+
170
+ ---
171
+
172
+ ### Media
173
+
174
+ Upload files to Levo's media library and get back fully resolved objects ready to use in your UI. Images are automatically processed into responsive variants from `320w` up to `2560w`, which you can use directly in `<img srcset>` or `<picture>` elements.
175
+
176
+ ```typescript
177
+ const formData = new FormData();
178
+ formData.append("files", file);
179
+
180
+ const { content } = await client.media.mediaBulkUpload(formData);
181
+ // content.data[0].location → full CDN URL
182
+ // content.data[0].srcset → { "320w": "...", "640w": "...", ... }
183
+ // content.data[0].metadata → { mimetype, size }
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Error Handling
189
+
190
+ Every module method can throw. Rather than catching raw `WretchError` or `TypeError` objects and trying to parse them, use `getLevoError()` to normalize everything into a predictable `LevoError` shape regardless of what went wrong.
191
+
192
+ ```typescript
193
+ import { getLevoError } from "@levo-so/core";
194
+
195
+ try {
196
+ await client.collection.saveCollectionData({ collection_id, data });
197
+ } catch (error) {
198
+ const err = getLevoError(error);
199
+
200
+ // err.code — machine-readable error code (e.g. "VALIDATION_ERROR")
201
+ // err.message — human-readable description
202
+ // err.status — HTTP status code
203
+
204
+ if (err.hasFieldErrors) {
205
+ // When the API returns field-level validation failures,
206
+ // wire these directly into your form library's error state
207
+ err.fieldErrors.forEach(({ param, message }) => {
208
+ form.setError(param, { message });
209
+ });
210
+ }
211
+ }
212
+ ```
213
+
214
+ `getLevoError()` covers all failure modes: HTTP 4xx/5xx errors with JSON bodies, network failures (offline, CORS, DNS), request timeouts, and already-normalized `LevoError` instances (passed through as-is, so it's safe to call twice).
215
+
216
+ A note on displaying errors: in modals and forms, prefer showing `err.message` inline near the action that failed rather than using a toast. Toasts are easy to miss; inline errors give users immediate, contextual feedback.
217
+
218
+ ---
219
+
220
+ ## Querying & Filtering
221
+
222
+ All list methods accept a query object for filtering, sorting, pagination, and field selection. This is the same query shape across all modules — blog, collections, media.
223
+
224
+ ```typescript
225
+ import type { LevoQuery } from "@levo-so/core";
226
+
227
+ const query: LevoQuery.FindQuery = {
228
+ page: 1,
229
+ limit: 20,
230
+ sort: { created_at: "desc" },
231
+
232
+ // Select only the fields you need
233
+ select: { _id: true, title: true, published_at: true },
234
+
235
+ // Filtering with AND/OR — nestable arbitrarily
236
+ where: {
237
+ AND: [
238
+ { status: { equals: "published" } },
239
+ { created_at: { gte: "2024-01-01" } },
240
+ { OR: [
241
+ { tag: { contains: "news" } },
242
+ { tag: { contains: "updates" } },
243
+ ]},
244
+ ],
245
+ },
246
+ };
247
+ ```
248
+
249
+ **Comparison operators:** `equals`, `not`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `contains`, `starts_with`, `ends_with`, `between`, `is_empty`, `is_not_empty`, `has`, `within` (geo radius)
250
+
251
+ **Logical operators:** `AND`, `OR` — nest at any depth.
252
+
253
+ ---
254
+
255
+ ## Utilities
256
+
257
+ **`getLevoError(error)`** — Normalize any thrown value into a `LevoError`. See [Error Handling](#error-handling).
258
+
259
+ **`formatImagePath(path)`** — URI-encodes the filename portion of a path. Use this when constructing image URLs that may contain spaces or special characters.
260
+
261
+ **`listToColumn(arr)`** — Converts `["name", "email"]` into `{ name: "name", email: "email" }`. Useful for building field selection objects programmatically.
262
+
263
+ **Type utilities** — `Prettify<T>`, `Mandatory<T, K>`, and `DeepPartial<T>` are exported from the package for use in consuming packages that build on top of `@levo-so/core`.
package/dist/client.js ADDED
@@ -0,0 +1,66 @@
1
+ import { createLevoControl as m } from "./control/index.js";
2
+ import { createHttpClient as d } from "./httpClient.js";
3
+ import { createLevoLogger as f } from "./logger/index.js";
4
+ import { createLevoBlogModule as C } from "./modules/blog.js";
5
+ import { createLevoCollectionModule as v } from "./modules/collection.js";
6
+ import { createLevoMediaModule as L } from "./modules/media.js";
7
+ import { createLevoMembershipModule as M } from "./modules/membership.js";
8
+ const D = (n) => {
9
+ const {
10
+ pageContext: c = {
11
+ url: typeof window < "u" ? window?.location?.href : "",
12
+ referrer: typeof document < "u" ? document?.referrer : "",
13
+ title: typeof document < "u" ? document?.title : ""
14
+ },
15
+ loggerOptions: i = {
16
+ logApiUrl: "https://public-api.levo.so"
17
+ },
18
+ ...p
19
+ } = n;
20
+ let o = c;
21
+ const e = m(p), t = d(e, o), a = C(e, t), l = M(e, t), g = v(e, t), u = L(e, t), s = f(e, i);
22
+ return {
23
+ get apiUrl() {
24
+ return e.apiUrl;
25
+ },
26
+ get workspace() {
27
+ return e.workspace;
28
+ },
29
+ get blog() {
30
+ return a;
31
+ },
32
+ get membership() {
33
+ return l;
34
+ },
35
+ get collection() {
36
+ return g;
37
+ },
38
+ get media() {
39
+ return u;
40
+ },
41
+ get logger() {
42
+ return s;
43
+ },
44
+ get fetch() {
45
+ return t.instance;
46
+ },
47
+ get APP_MODE() {
48
+ return e.APP_MODE;
49
+ },
50
+ get NODE_ENV() {
51
+ return e.NODE_ENV;
52
+ },
53
+ get pageContext() {
54
+ return o;
55
+ },
56
+ updatePageContext: (r) => {
57
+ o = Object.assign({}, o, r);
58
+ },
59
+ updateWorkspace: (r) => {
60
+ e.workspace = r;
61
+ }
62
+ };
63
+ };
64
+ export {
65
+ D as createLevoClient
66
+ };
@@ -0,0 +1,33 @@
1
+ const l = {
2
+ "public-id": "",
3
+ string: "",
4
+ number: null,
5
+ boolean: !1,
6
+ location: null,
7
+ file: null,
8
+ date: null,
9
+ identifier: null,
10
+ record: null,
11
+ group: [],
12
+ collection: {
13
+ m2o: null,
14
+ o2o: null,
15
+ m2m: []
16
+ },
17
+ richtext: {
18
+ html: "",
19
+ text: "",
20
+ json: []
21
+ },
22
+ json: {},
23
+ "array-string": [],
24
+ "array-number": [],
25
+ "array-file": [],
26
+ "array-json": [],
27
+ "array-date": [],
28
+ "array-boolean": [],
29
+ "array-location": []
30
+ };
31
+ export {
32
+ l as defaultByKinds
33
+ };
@@ -0,0 +1,34 @@
1
+ const e = {
2
+ ArrayWidget: [],
3
+ RecordWidget: [],
4
+ TextWidget: [],
5
+ MultiTextWidget: [],
6
+ CurrencyWidget: [],
7
+ TextareaWidget: [],
8
+ GeocoderWidget: [],
9
+ DropdownWidget: [],
10
+ ToggleCheckboxWidget: [],
11
+ RadioWidget: [],
12
+ NumberWidget: [],
13
+ EmailWidget: ["email"],
14
+ PhoneWidget: ["phone"],
15
+ URLWidget: ["url"],
16
+ DateWidget: [],
17
+ TimeWidget: [],
18
+ SwitchWidget: [],
19
+ RichTextWidget: [],
20
+ DateTimeWidget: [],
21
+ CheckboxWidget: [],
22
+ ImageUploadWidget: [],
23
+ MultiImageUploadWidget: [],
24
+ FileUploadWidget: [],
25
+ MultiFileUploadWidget: [],
26
+ MultiDropdownWidget: [],
27
+ CollectionWidget: [],
28
+ MultiGeocoderWidget: [],
29
+ JSONWidget: [],
30
+ SlugWidget: []
31
+ };
32
+ export {
33
+ e as formatsByInterface
34
+ };
@@ -0,0 +1,48 @@
1
+ const i = [
2
+ "TextWidget",
3
+ "CurrencyWidget",
4
+ "TextareaWidget",
5
+ "GeocoderWidget",
6
+ "MultiGeocoderWidget",
7
+ "DropdownWidget",
8
+ "ToggleCheckboxWidget",
9
+ "RadioWidget",
10
+ "NumberWidget",
11
+ "EmailWidget",
12
+ "PhoneWidget",
13
+ "URLWidget",
14
+ "DateWidget",
15
+ "TimeWidget",
16
+ "SwitchWidget",
17
+ "RichTextWidget",
18
+ "DateTimeWidget",
19
+ "CheckboxWidget",
20
+ "ImageUploadWidget",
21
+ "MultiImageUploadWidget",
22
+ "FileUploadWidget",
23
+ "MultiFileUploadWidget",
24
+ "MultiDropdownWidget",
25
+ "MultiTextWidget"
26
+ ], d = [
27
+ "CollectionWidget",
28
+ "ArrayWidget",
29
+ "RecordWidget",
30
+ "JSONWidget",
31
+ "SlugWidget"
32
+ ], g = [
33
+ ...i,
34
+ ...d
35
+ ], o = i.reduce(
36
+ (e, t) => (e[t] = t, e),
37
+ {}
38
+ ), W = g.reduce(
39
+ (e, t) => (e[t] = t, e),
40
+ {}
41
+ );
42
+ export {
43
+ o as CommonFieldIntefaces,
44
+ i as CommonFieldInterfacesList,
45
+ d as ComplexFieldInterfacesList,
46
+ W as FieldInterfaces,
47
+ g as FieldInterfacesList
48
+ };
@@ -0,0 +1,78 @@
1
+ const i = [
2
+ "collection",
3
+ "record",
4
+ "group",
5
+ "public-id",
6
+ "string",
7
+ "array-string",
8
+ "number",
9
+ "array-number",
10
+ "date",
11
+ "array-date",
12
+ "boolean",
13
+ "array-boolean",
14
+ "json",
15
+ "array-json",
16
+ "file",
17
+ "array-file",
18
+ "location",
19
+ "array-location",
20
+ "richtext",
21
+ "identifier"
22
+ ], r = {
23
+ identifier: "identifier",
24
+ collection: "collection",
25
+ "public-id": "public-id",
26
+ string: "string",
27
+ number: "number",
28
+ boolean: "boolean",
29
+ date: "date",
30
+ json: "json",
31
+ "array-string": "array-string",
32
+ "array-number": "array-number",
33
+ "array-date": "array-date",
34
+ "array-boolean": "array-boolean",
35
+ file: "file",
36
+ "array-file": "array-file",
37
+ record: "record",
38
+ group: "group",
39
+ location: "location",
40
+ "array-location": "array-location",
41
+ richtext: "richtext",
42
+ "array-json": "array-json"
43
+ }, e = {
44
+ ArrayWidget: r.group,
45
+ RecordWidget: r.record,
46
+ TextWidget: r.string,
47
+ TextareaWidget: r.string,
48
+ EmailWidget: r.string,
49
+ URLWidget: r.string,
50
+ CurrencyWidget: r.string,
51
+ NumberWidget: r.number,
52
+ PhoneWidget: r.string,
53
+ SlugWidget: r.string,
54
+ RichTextWidget: r.string,
55
+ DateWidget: r.date,
56
+ TimeWidget: r.string,
57
+ DateTimeWidget: r.date,
58
+ RadioWidget: r.string,
59
+ CheckboxWidget: r["array-string"],
60
+ ToggleCheckboxWidget: r.boolean,
61
+ SwitchWidget: r.boolean,
62
+ DropdownWidget: r.string,
63
+ MultiDropdownWidget: r["array-string"],
64
+ MultiTextWidget: r["array-string"],
65
+ FileUploadWidget: r.file,
66
+ MultiFileUploadWidget: r["array-file"],
67
+ MultiImageUploadWidget: r["array-file"],
68
+ ImageUploadWidget: r.file,
69
+ GeocoderWidget: r.location,
70
+ MultiGeocoderWidget: r["array-location"],
71
+ CollectionWidget: r.collection,
72
+ JSONWidget: r.json
73
+ };
74
+ export {
75
+ r as FieldKind,
76
+ i as FieldKindList,
77
+ e as interfaceKinds
78
+ };
@@ -0,0 +1,13 @@
1
+ const o = {
2
+ beacon: "so.levo.beacon",
3
+ blog: "so.levo.blog",
4
+ event: "so.levo.event",
5
+ membership: "so.levo.membership",
6
+ payment: "so.levo.payment",
7
+ community: "so.levo.community",
8
+ search_console: "com.google.search_console"
9
+ }, e = Object.values(o);
10
+ export {
11
+ o as LEVO_INTEGRATIONS,
12
+ e as LEVO_INTEGRATIONS_LIST
13
+ };
@@ -0,0 +1,8 @@
1
+ const i = ["image", "video", "audio", "document"], o = i.reduce(
2
+ (e, d) => (e[d] = d, e),
3
+ {}
4
+ );
5
+ export {
6
+ o as MediaKind,
7
+ i as MediaKindList
8
+ };
@@ -0,0 +1,4 @@
1
+ const o = ["google", "linkedin", "microsoft"];
2
+ export {
3
+ o as LevoOAuthProviderList
4
+ };
@@ -0,0 +1,42 @@
1
+ const o = /^W[A-Z0-9]{7}$/, i = (s) => {
2
+ const {
3
+ appName: a,
4
+ workspace: r = null,
5
+ apiUrl: p = "https://public-api.levo.so",
6
+ NODE_ENV: n = "",
7
+ APP_MODE: c = "production"
8
+ } = s;
9
+ if (r && !o.test(r))
10
+ throw new Error(
11
+ `Invalid workspace ID: ${r}. Workspace ID must be a string of the format "W[A-Z0-9]{7}".`
12
+ );
13
+ let t = r;
14
+ return {
15
+ get workspace() {
16
+ return t;
17
+ },
18
+ set workspace(e) {
19
+ if (e && !o.test(e))
20
+ throw new Error(
21
+ `Invalid workspace ID: ${e}. Workspace ID must be a string of the format "W[A-Z0-9]{7}".`
22
+ );
23
+ t = e;
24
+ },
25
+ get appName() {
26
+ return a;
27
+ },
28
+ get apiUrl() {
29
+ return p;
30
+ },
31
+ get NODE_ENV() {
32
+ return n;
33
+ },
34
+ get APP_MODE() {
35
+ return c;
36
+ }
37
+ };
38
+ };
39
+ export {
40
+ i as createLevoControl,
41
+ o as workspaceIdRegex
42
+ };
@@ -0,0 +1,26 @@
1
+ import s from "wretch";
2
+ import n from "wretch/addons/queryString";
3
+ const c = (r, o) => {
4
+ const t = s(r.apiUrl).addon(n);
5
+ return {
6
+ get instance() {
7
+ const e = {
8
+ credentials: "include",
9
+ signal: AbortSignal.timeout(3e4),
10
+ // 30 seconds timeout,
11
+ headers: {}
12
+ };
13
+ return r.workspace && (e.headers = {
14
+ ...e?.headers || {},
15
+ "Levo-Workspace": r.workspace
16
+ }), o && typeof o == "object" && Object.keys(o).length > 0 && (e.headers = {
17
+ ...e?.headers || {},
18
+ "Levo-Page-Context": encodeURIComponent(JSON.stringify(o))
19
+ }), r.workspace ? t.options(e).query({ workspace: r.workspace }) : t.options(e);
20
+ },
21
+ pageContext: o
22
+ };
23
+ };
24
+ export {
25
+ c as createHttpClient
26
+ };