@otomata/editable 0.2.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.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/core/accent.d.ts +9 -0
- package/dist/core/accent.js +21 -0
- package/dist/core/define.d.ts +2 -0
- package/dist/core/define.js +3 -0
- package/dist/core/index.d.ts +6 -0
- package/dist/core/index.js +5 -0
- package/dist/core/merge.d.ts +2 -0
- package/dist/core/merge.js +13 -0
- package/dist/core/paths.d.ts +4 -0
- package/dist/core/paths.js +30 -0
- package/dist/core/types.d.ts +5 -0
- package/dist/core/types.js +1 -0
- package/dist/core/validate.d.ts +1 -0
- package/dist/core/validate.js +45 -0
- package/dist/fastify/index.d.ts +4 -0
- package/dist/fastify/index.js +2 -0
- package/dist/fastify/plugin.d.ts +17 -0
- package/dist/fastify/plugin.js +45 -0
- package/dist/fastify/storage.d.ts +52 -0
- package/dist/fastify/storage.js +51 -0
- package/package.json +66 -0
- package/src/core/accent.ts +21 -0
- package/src/core/define.ts +5 -0
- package/src/core/index.ts +6 -0
- package/src/core/merge.ts +12 -0
- package/src/core/paths.ts +28 -0
- package/src/core/types.ts +5 -0
- package/src/core/validate.ts +41 -0
- package/src/env.d.ts +5 -0
- package/src/fastify/index.ts +4 -0
- package/src/fastify/plugin.ts +61 -0
- package/src/fastify/storage.ts +72 -0
- package/src/vue/CmsList.vue +122 -0
- package/src/vue/CmsPage.vue +160 -0
- package/src/vue/CmsPopover.vue +165 -0
- package/src/vue/CmsText.vue +105 -0
- package/src/vue/CmsToolbar.vue +175 -0
- package/src/vue/client.ts +27 -0
- package/src/vue/editState.ts +38 -0
- package/src/vue/index.ts +7 -0
- package/src/vue/useContent.ts +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexis Laporte (Otomata)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# @otomata/editable
|
|
2
|
+
|
|
3
|
+
Micro-CMS embarqué pour les apps Otomata : les defaults du contenu vivent dans le code (registry TypeScript partagé front/back), les overrides édités vivent dans une table key/value. Édition inline dans la page (crayon → popovers), validation de forme côté serveur, retour aux defaults en un clic. Trois couches : `@otomata/editable` (core pur), `@otomata/editable/fastify` (plugin REST), `@otomata/editable/vue` (composants d'édition).
|
|
4
|
+
|
|
5
|
+
## Registry partagé
|
|
6
|
+
|
|
7
|
+
Le registry est le contrat : un module importable des deux côtés.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// shared/content-registry.ts
|
|
11
|
+
import { defineContent } from "@otomata/editable";
|
|
12
|
+
|
|
13
|
+
export const contentRegistry = {
|
|
14
|
+
landing: defineContent({
|
|
15
|
+
hero: { title: "Bienvenue", subtitle: "Sous-titre <accent>accentué</accent>" },
|
|
16
|
+
features: [{ label: "Rapide", detail: "…" }],
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Les clés manquantes côté stockage sont comblées par les defaults (`mergeDeep`) ; les clés inconnues sont rejetées au `PUT` (`validateShape`). Modifier les defaults dans le code se propage donc sans migration.
|
|
22
|
+
|
|
23
|
+
## Backend Fastify
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { contentPlugin, settingStorage } from "@otomata/editable/fastify";
|
|
27
|
+
import { contentRegistry } from "../shared/content-registry.js";
|
|
28
|
+
|
|
29
|
+
await app.register(contentPlugin, {
|
|
30
|
+
prefix: "/api/content",
|
|
31
|
+
registry: contentRegistry,
|
|
32
|
+
storage: settingStorage(prisma.setting, { prefix: "content:" }),
|
|
33
|
+
adminGuards: [requireAdmin], // preHandlers Fastify ; GET reste public
|
|
34
|
+
getActor: (req) => req.user?.email, // optionnel, passé à storage.set
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Routes : `GET /:slug` → `{ current, defaults }` ; `PUT /:slug` (guards) → 400 `Champ de contenu invalide : <path>` si la forme dévie ; `POST /:slug/reset` (guards) → defaults.
|
|
39
|
+
|
|
40
|
+
`settingStorage` attend un delegate **structurel** style Prisma sur une table `{ key: string @id, value: string }` (`findUnique`/`upsert`/`deleteMany`) — aucun import Prisma dans le package. `memoryStorage()` pour les tests.
|
|
41
|
+
|
|
42
|
+
### Consommateur Drizzle (waome, drizzle-orm 0.45)
|
|
43
|
+
|
|
44
|
+
Pas d'adaptateur dédié : implémenter `ContentStorage` directement.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { eq } from "drizzle-orm";
|
|
48
|
+
import type { ContentStorage } from "@otomata/editable/fastify";
|
|
49
|
+
|
|
50
|
+
const key = (slug: string) => `content:${slug}`;
|
|
51
|
+
export const storage: ContentStorage = {
|
|
52
|
+
async get(slug) {
|
|
53
|
+
const [row] = await db.select().from(settings).where(eq(settings.key, key(slug)));
|
|
54
|
+
return row ? JSON.parse(row.value) : null;
|
|
55
|
+
},
|
|
56
|
+
async set(slug, data) {
|
|
57
|
+
const value = JSON.stringify(data);
|
|
58
|
+
await db.insert(settings).values({ key: key(slug), value })
|
|
59
|
+
.onConflictDoUpdate({ target: settings.key, set: { value } });
|
|
60
|
+
},
|
|
61
|
+
async remove(slug) {
|
|
62
|
+
await db.delete(settings).where(eq(settings.key, key(slug)));
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Frontend Vue
|
|
68
|
+
|
|
69
|
+
Installer le plugin (une fois) :
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { contentClient } from "@otomata/editable/vue";
|
|
73
|
+
import { contentRegistry } from "../shared/content-registry.js";
|
|
74
|
+
|
|
75
|
+
app.use(contentClient({
|
|
76
|
+
registry: contentRegistry,
|
|
77
|
+
baseUrl: "/api/content", // défaut
|
|
78
|
+
getAuthHeaders: async () => ({ authorization: `Bearer ${await getToken()}` }),
|
|
79
|
+
}));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Dans une page :
|
|
83
|
+
|
|
84
|
+
```vue
|
|
85
|
+
<script setup lang="ts">
|
|
86
|
+
import { CmsPage, CmsText, CmsList } from "@otomata/editable/vue";
|
|
87
|
+
const isAdmin = /* … */;
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<template>
|
|
91
|
+
<CmsPage slug="landing" :can-edit="isAdmin">
|
|
92
|
+
<h1><CmsText path="hero.title" tag="span" /></h1>
|
|
93
|
+
<p><CmsText path="hero.subtitle" accent /></p>
|
|
94
|
+
<ul>
|
|
95
|
+
<CmsList path="features" v-slot="{ path }">
|
|
96
|
+
<li><CmsText :path="`${path}.label`" /></li>
|
|
97
|
+
</CmsList>
|
|
98
|
+
</ul>
|
|
99
|
+
</CmsPage>
|
|
100
|
+
</template>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Rendu **immédiat** sur les defaults, hydratation `GET` au mounted (échec réseau → defaults, warn console).
|
|
104
|
+
- `canEdit` affiche un crayon flottant ; en édition : clic sur un `CmsText` → popover textarea (preview live), barre flottante Enregistrer / Réinitialiser / Quitter.
|
|
105
|
+
- `CmsText` : interpolation texte uniquement (jamais `v-html`) ; `\n` → `<br>` ; `accent` rend les segments `<accent>…</accent>` en `<span class="cms-accent">` (couleur via `--cms-accent-color`).
|
|
106
|
+
- `CmsList` : "+ Ajouter" clone le **premier élément des defaults** ; tableau de defaults vide → pas de bouton.
|
|
107
|
+
- Lecture seule hors `CmsPage` : `useContent(slug)` → `{ content, loading }`.
|
|
108
|
+
|
|
109
|
+
## Distribution
|
|
110
|
+
|
|
111
|
+
**Dans le monorepo** (npm workspaces) : dépendance `"@otomata/editable": "*"`, résolue en local. `.` et `./fastify` pointent vers `dist/` (committer ou builder avant usage : `npm run build --workspace @otomata/editable`) ; `./vue` reste en source brute, compilée par le Vite du consommateur.
|
|
112
|
+
|
|
113
|
+
**Hors monorepo** (ex. waome) : pas de publication npm — vendoring par tarball.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
cd oto-app && npm pack --workspace @otomata/editable # → otomata-editable-0.1.0.tgz
|
|
117
|
+
mv otomata-editable-*.tgz <consumer>/vendor/
|
|
118
|
+
cd <consumer> && npm install ./vendor/otomata-editable-0.1.0.tgz
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`prepack` rebuilde `dist/` ; le tarball contient `src/` + `dist/`.
|
|
122
|
+
|
|
123
|
+
## Pièges connus
|
|
124
|
+
|
|
125
|
+
- **Install par tarball + Vite** : ajouter `optimizeDeps: { exclude: ["@otomata/editable"] }` — l'entrée `./vue` est du TS/SFC brut, le pré-bundling esbuild de Vite ne doit pas y toucher.
|
|
126
|
+
- **Styles** : composants autonomes (scoped, zéro Tailwind), personnalisables uniquement via les tokens `--cms-*` (`--cms-ink`, `--cms-surface`, `--cms-hair`, `--cms-accent`, `--cms-danger`, `--cms-accent-color`, `--cms-font-mono`, `--cms-banner-bg`, `--cms-banner-ink`). Le bandeau admin **réserve sa hauteur** (padding du `body`) et publie `--cms-banner-height` : les éléments `position: fixed` en haut de page doivent se décaler avec `top: var(--cms-banner-height, 0px)`.
|
|
127
|
+
- **Concurrence** : pas de verrou ni de versioning — un seul admin à la fois, dernier `PUT` gagne (last-write-wins).
|
|
128
|
+
- **Peer deps optionnelles** : `vue` et `fastify` sont des peers `optional` — installer celui qui correspond à la couche consommée.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type AccentSegment = {
|
|
2
|
+
text: string;
|
|
3
|
+
accent: boolean;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Découpe un texte brut en lignes (\n) puis en segments accentués / non accentués
|
|
7
|
+
* sur les balises <accent>…</accent>. Une balise non fermée reste du texte brut.
|
|
8
|
+
*/
|
|
9
|
+
export declare function splitAccentLines(raw: string): AccentSegment[][];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Découpe un texte brut en lignes (\n) puis en segments accentués / non accentués
|
|
3
|
+
* sur les balises <accent>…</accent>. Une balise non fermée reste du texte brut.
|
|
4
|
+
*/
|
|
5
|
+
export function splitAccentLines(raw) {
|
|
6
|
+
return raw.split("\n").map((line) => {
|
|
7
|
+
const segments = [];
|
|
8
|
+
const re = /<accent>(.*?)<\/accent>/g;
|
|
9
|
+
let last = 0;
|
|
10
|
+
let m;
|
|
11
|
+
while ((m = re.exec(line)) !== null) {
|
|
12
|
+
if (m.index > last)
|
|
13
|
+
segments.push({ text: line.slice(last, m.index), accent: false });
|
|
14
|
+
segments.push({ text: m[1] ?? "", accent: true });
|
|
15
|
+
last = m.index + m[0].length;
|
|
16
|
+
}
|
|
17
|
+
if (last < line.length)
|
|
18
|
+
segments.push({ text: line.slice(last), accent: false });
|
|
19
|
+
return segments;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { ContentDef, ContentRegistry, ContentDataOf } from "./types.js";
|
|
2
|
+
export { defineContent } from "./define.js";
|
|
3
|
+
export { mergeDeep } from "./merge.js";
|
|
4
|
+
export { validateShape } from "./validate.js";
|
|
5
|
+
export { getByPath, setByPath } from "./paths.js";
|
|
6
|
+
export { splitAccentLines, type AccentSegment } from "./accent.js";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Deep-merge : objets fusionnés récursivement ; tableaux et primitifs remplacés par l'override ; override undefined → base. */
|
|
2
|
+
export function mergeDeep(base, override) {
|
|
3
|
+
if (Array.isArray(base) || override === null || typeof override !== "object" || Array.isArray(override)) {
|
|
4
|
+
return override === undefined ? base : override;
|
|
5
|
+
}
|
|
6
|
+
if (base === null || typeof base !== "object")
|
|
7
|
+
return override;
|
|
8
|
+
const out = { ...base };
|
|
9
|
+
for (const [k, v] of Object.entries(override)) {
|
|
10
|
+
out[k] = k in out ? mergeDeep(out[k], v) : v;
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Lecture par path pointé ('hero.title', 'portes.0.label'). */
|
|
2
|
+
export declare function getByPath(obj: unknown, path: string): unknown;
|
|
3
|
+
/** Écriture immutable : clone uniquement le chemin touché, supporte les indices de tableaux. */
|
|
4
|
+
export declare function setByPath<T>(obj: T, path: string, value: unknown): T;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Lecture par path pointé ('hero.title', 'portes.0.label'). */
|
|
2
|
+
export function getByPath(obj, path) {
|
|
3
|
+
if (!path)
|
|
4
|
+
return obj;
|
|
5
|
+
let cur = obj;
|
|
6
|
+
for (const p of path.split(".")) {
|
|
7
|
+
if (cur == null)
|
|
8
|
+
return undefined;
|
|
9
|
+
cur = cur[p];
|
|
10
|
+
}
|
|
11
|
+
return cur;
|
|
12
|
+
}
|
|
13
|
+
/** Écriture immutable : clone uniquement le chemin touché, supporte les indices de tableaux. */
|
|
14
|
+
export function setByPath(obj, path, value) {
|
|
15
|
+
if (!path)
|
|
16
|
+
return value;
|
|
17
|
+
const parts = path.split(".");
|
|
18
|
+
const root = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
19
|
+
let cur = root;
|
|
20
|
+
// `as string` : l'index est toujours < parts.length, mais les consommateurs
|
|
21
|
+
// avec noUncheckedIndexedAccess voient string | undefined.
|
|
22
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
23
|
+
const k = parts[i];
|
|
24
|
+
const next = cur[k];
|
|
25
|
+
cur[k] = Array.isArray(next) ? [...next] : { ...(next ?? {}) };
|
|
26
|
+
cur = cur[k];
|
|
27
|
+
}
|
|
28
|
+
cur[parts[parts.length - 1]] = value;
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function validateShape(data: unknown, shape: unknown, path?: string): string | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valide que `data` respecte la FORME de `shape` (les defaults d'un slug) :
|
|
3
|
+
* pas de clé inconnue (typo), types primitifs identiques, tableaux validés
|
|
4
|
+
* élément par élément contre le template (1er élément des defaults).
|
|
5
|
+
* Les clés manquantes sont acceptées : mergeDeep comble avec les defaults.
|
|
6
|
+
* Renvoie le path pointé du premier champ invalide, ou null si conforme.
|
|
7
|
+
*/
|
|
8
|
+
function isPlainObject(v) {
|
|
9
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
10
|
+
}
|
|
11
|
+
export function validateShape(data, shape, path = "") {
|
|
12
|
+
// Pas de contrainte de forme (ex. champ libre) → accepté.
|
|
13
|
+
if (shape === null || shape === undefined)
|
|
14
|
+
return null;
|
|
15
|
+
if (Array.isArray(shape)) {
|
|
16
|
+
if (!Array.isArray(data))
|
|
17
|
+
return path || "(racine)";
|
|
18
|
+
const template = shape.length > 0 ? shape[0] : null;
|
|
19
|
+
if (template === null)
|
|
20
|
+
return null; // tableau de defaults vide : pas de template, on accepte
|
|
21
|
+
for (let i = 0; i < data.length; i++) {
|
|
22
|
+
const err = validateShape(data[i], template, `${path}[${i}]`);
|
|
23
|
+
if (err)
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (isPlainObject(shape)) {
|
|
29
|
+
if (!isPlainObject(data))
|
|
30
|
+
return path || "(racine)";
|
|
31
|
+
for (const key of Object.keys(data)) {
|
|
32
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
33
|
+
if (!(key in shape))
|
|
34
|
+
return childPath;
|
|
35
|
+
const err = validateShape(data[key], shape[key], childPath);
|
|
36
|
+
if (err)
|
|
37
|
+
return err;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// Primitif : le type doit correspondre à celui du default.
|
|
42
|
+
if (typeof data !== typeof shape)
|
|
43
|
+
return path || "(racine)";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { FastifyPluginAsync, FastifyRequest, preHandlerHookHandler } from "fastify";
|
|
2
|
+
import type { ContentRegistry } from "../core/index.js";
|
|
3
|
+
import type { ContentStorage } from "./storage.js";
|
|
4
|
+
export type ContentPluginOptions = {
|
|
5
|
+
registry: ContentRegistry;
|
|
6
|
+
storage: ContentStorage;
|
|
7
|
+
adminGuards: preHandlerHookHandler[];
|
|
8
|
+
getActor?: (request: FastifyRequest) => string | undefined;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Plugin Fastify exposant le CRUD de contenu éditorial, scopé au prefix
|
|
12
|
+
* donné au register (ex. `/api/content`) :
|
|
13
|
+
* GET /:slug → { current, defaults } (public)
|
|
14
|
+
* PUT /:slug → validation de forme + persistance (adminGuards)
|
|
15
|
+
* POST /:slug/reset → retour aux defaults (adminGuards)
|
|
16
|
+
*/
|
|
17
|
+
export declare const contentPlugin: FastifyPluginAsync<ContentPluginOptions>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mergeDeep, validateShape } from "../core/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Plugin Fastify exposant le CRUD de contenu éditorial, scopé au prefix
|
|
4
|
+
* donné au register (ex. `/api/content`) :
|
|
5
|
+
* GET /:slug → { current, defaults } (public)
|
|
6
|
+
* PUT /:slug → validation de forme + persistance (adminGuards)
|
|
7
|
+
* POST /:slug/reset → retour aux defaults (adminGuards)
|
|
8
|
+
*/
|
|
9
|
+
export const contentPlugin = async (app, opts) => {
|
|
10
|
+
const { registry, storage, adminGuards, getActor } = opts;
|
|
11
|
+
app.get("/:slug", async (request, reply) => {
|
|
12
|
+
const { slug } = request.params;
|
|
13
|
+
const def = registry[slug];
|
|
14
|
+
if (!def) {
|
|
15
|
+
return reply.code(404).send({ error: `Contenu inconnu : ${slug}` });
|
|
16
|
+
}
|
|
17
|
+
const stored = await storage.get(slug);
|
|
18
|
+
return {
|
|
19
|
+
current: mergeDeep(def.defaults, stored ?? undefined),
|
|
20
|
+
defaults: def.defaults,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
app.put("/:slug", { preHandler: adminGuards }, async (request, reply) => {
|
|
24
|
+
const { slug } = request.params;
|
|
25
|
+
const def = registry[slug];
|
|
26
|
+
if (!def) {
|
|
27
|
+
return reply.code(404).send({ error: `Contenu inconnu : ${slug}` });
|
|
28
|
+
}
|
|
29
|
+
const invalidPath = validateShape(request.body, def.defaults);
|
|
30
|
+
if (invalidPath !== null) {
|
|
31
|
+
return reply.code(400).send({ error: `Champ de contenu invalide : ${invalidPath}` });
|
|
32
|
+
}
|
|
33
|
+
await storage.set(slug, request.body, getActor?.(request));
|
|
34
|
+
return { current: request.body };
|
|
35
|
+
});
|
|
36
|
+
app.post("/:slug/reset", { preHandler: adminGuards }, async (request, reply) => {
|
|
37
|
+
const { slug } = request.params;
|
|
38
|
+
const def = registry[slug];
|
|
39
|
+
if (!def) {
|
|
40
|
+
return reply.code(404).send({ error: `Contenu inconnu : ${slug}` });
|
|
41
|
+
}
|
|
42
|
+
await storage.remove(slug);
|
|
43
|
+
return { current: def.defaults };
|
|
44
|
+
});
|
|
45
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstractions de persistance du contenu éditorial.
|
|
3
|
+
* Le plugin Fastify ne connaît que l'interface ContentStorage ;
|
|
4
|
+
* deux implémentations : mémoire (tests/dev) et table Setting key/value (Prisma).
|
|
5
|
+
*/
|
|
6
|
+
export interface ContentStorage {
|
|
7
|
+
get(slug: string): Promise<unknown | null>;
|
|
8
|
+
set(slug: string, data: unknown, updatedBy?: string): Promise<void>;
|
|
9
|
+
remove(slug: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** Stockage en mémoire (tests, dev). */
|
|
12
|
+
export declare function memoryStorage(): ContentStorage;
|
|
13
|
+
/**
|
|
14
|
+
* Type STRUCTUREL compatible avec le delegate Prisma d'une table key/value :
|
|
15
|
+
* model Setting { key String @id ; value String }.
|
|
16
|
+
* Aucun import prisma : n'importe quel objet conforme convient.
|
|
17
|
+
*/
|
|
18
|
+
export type SettingDelegate = {
|
|
19
|
+
findUnique(args: {
|
|
20
|
+
where: {
|
|
21
|
+
key: string;
|
|
22
|
+
};
|
|
23
|
+
}): Promise<{
|
|
24
|
+
key: string;
|
|
25
|
+
value: string;
|
|
26
|
+
} | null>;
|
|
27
|
+
upsert(args: {
|
|
28
|
+
where: {
|
|
29
|
+
key: string;
|
|
30
|
+
};
|
|
31
|
+
update: {
|
|
32
|
+
value: string;
|
|
33
|
+
};
|
|
34
|
+
create: {
|
|
35
|
+
key: string;
|
|
36
|
+
value: string;
|
|
37
|
+
};
|
|
38
|
+
}): Promise<unknown>;
|
|
39
|
+
deleteMany(args: {
|
|
40
|
+
where: {
|
|
41
|
+
key: string;
|
|
42
|
+
};
|
|
43
|
+
}): Promise<unknown>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Stockage sur une table Setting key/value.
|
|
47
|
+
* slug → clé = (prefix ?? '') + slug ; valeurs sérialisées en JSON.
|
|
48
|
+
* JSON invalide en base → console.warn + null (équivaut à « pas de donnée »).
|
|
49
|
+
*/
|
|
50
|
+
export declare function settingStorage(setting: SettingDelegate, opts?: {
|
|
51
|
+
prefix?: string;
|
|
52
|
+
}): ContentStorage;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstractions de persistance du contenu éditorial.
|
|
3
|
+
* Le plugin Fastify ne connaît que l'interface ContentStorage ;
|
|
4
|
+
* deux implémentations : mémoire (tests/dev) et table Setting key/value (Prisma).
|
|
5
|
+
*/
|
|
6
|
+
/** Stockage en mémoire (tests, dev). */
|
|
7
|
+
export function memoryStorage() {
|
|
8
|
+
const store = new Map();
|
|
9
|
+
return {
|
|
10
|
+
async get(slug) {
|
|
11
|
+
return store.has(slug) ? store.get(slug) : null;
|
|
12
|
+
},
|
|
13
|
+
async set(slug, data) {
|
|
14
|
+
store.set(slug, data);
|
|
15
|
+
},
|
|
16
|
+
async remove(slug) {
|
|
17
|
+
store.delete(slug);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Stockage sur une table Setting key/value.
|
|
23
|
+
* slug → clé = (prefix ?? '') + slug ; valeurs sérialisées en JSON.
|
|
24
|
+
* JSON invalide en base → console.warn + null (équivaut à « pas de donnée »).
|
|
25
|
+
*/
|
|
26
|
+
export function settingStorage(setting, opts) {
|
|
27
|
+
const prefix = opts?.prefix ?? "";
|
|
28
|
+
const keyOf = (slug) => `${prefix}${slug}`;
|
|
29
|
+
return {
|
|
30
|
+
async get(slug) {
|
|
31
|
+
const row = await setting.findUnique({ where: { key: keyOf(slug) } });
|
|
32
|
+
if (!row)
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(row.value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
console.warn(`@otomata/editable: JSON invalide en base pour la clé "${keyOf(slug)}", ignoré`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
async set(slug, data) {
|
|
43
|
+
const key = keyOf(slug);
|
|
44
|
+
const value = JSON.stringify(data);
|
|
45
|
+
await setting.upsert({ where: { key }, update: { value }, create: { key, value } });
|
|
46
|
+
},
|
|
47
|
+
async remove(slug) {
|
|
48
|
+
await setting.deleteMany({ where: { key: keyOf(slug) } });
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@otomata/editable",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Embeddable micro-CMS: content defaults in code, overrides in a key/value table, inline page editing. Core + Fastify plugin + Vue components.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Alexis Laporte (Otomata)",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/otomata-tech/otomata-editable.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/otomata-tech/otomata-editable#readme",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"cms",
|
|
14
|
+
"micro-cms",
|
|
15
|
+
"inline-editing",
|
|
16
|
+
"fastify",
|
|
17
|
+
"vue",
|
|
18
|
+
"content"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/core/index.d.ts",
|
|
27
|
+
"default": "./dist/core/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./fastify": {
|
|
30
|
+
"types": "./dist/fastify/index.d.ts",
|
|
31
|
+
"default": "./dist/fastify/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./vue": "./src/vue/index.ts"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.build.json",
|
|
41
|
+
"prepack": "npm run build",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"vue": "^3.5.0",
|
|
47
|
+
"fastify": "^5.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"vue": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"fastify": {
|
|
54
|
+
"optional": true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"vue": "^3.5.0",
|
|
59
|
+
"fastify": "^5.0.0",
|
|
60
|
+
"typescript": "~5.9.0",
|
|
61
|
+
"vitest": "^4.0.0",
|
|
62
|
+
"vue-tsc": "^3.0.0",
|
|
63
|
+
"@vitejs/plugin-vue": "^6.0.0",
|
|
64
|
+
"jsdom": "^28.0.0"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type AccentSegment = { text: string; accent: boolean };
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Découpe un texte brut en lignes (\n) puis en segments accentués / non accentués
|
|
5
|
+
* sur les balises <accent>…</accent>. Une balise non fermée reste du texte brut.
|
|
6
|
+
*/
|
|
7
|
+
export function splitAccentLines(raw: string): AccentSegment[][] {
|
|
8
|
+
return raw.split("\n").map((line) => {
|
|
9
|
+
const segments: AccentSegment[] = [];
|
|
10
|
+
const re = /<accent>(.*?)<\/accent>/g;
|
|
11
|
+
let last = 0;
|
|
12
|
+
let m: RegExpExecArray | null;
|
|
13
|
+
while ((m = re.exec(line)) !== null) {
|
|
14
|
+
if (m.index > last) segments.push({ text: line.slice(last, m.index), accent: false });
|
|
15
|
+
segments.push({ text: m[1] ?? "", accent: true });
|
|
16
|
+
last = m.index + m[0].length;
|
|
17
|
+
}
|
|
18
|
+
if (last < line.length) segments.push({ text: line.slice(last), accent: false });
|
|
19
|
+
return segments;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { ContentDef, ContentRegistry, ContentDataOf } from "./types.js";
|
|
2
|
+
export { defineContent } from "./define.js";
|
|
3
|
+
export { mergeDeep } from "./merge.js";
|
|
4
|
+
export { validateShape } from "./validate.js";
|
|
5
|
+
export { getByPath, setByPath } from "./paths.js";
|
|
6
|
+
export { splitAccentLines, type AccentSegment } from "./accent.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Deep-merge : objets fusionnés récursivement ; tableaux et primitifs remplacés par l'override ; override undefined → base. */
|
|
2
|
+
export function mergeDeep<T>(base: T, override: unknown): T {
|
|
3
|
+
if (Array.isArray(base) || override === null || typeof override !== "object" || Array.isArray(override)) {
|
|
4
|
+
return override === undefined ? base : (override as T);
|
|
5
|
+
}
|
|
6
|
+
if (base === null || typeof base !== "object") return override as T;
|
|
7
|
+
const out: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
8
|
+
for (const [k, v] of Object.entries(override as Record<string, unknown>)) {
|
|
9
|
+
out[k] = k in out ? mergeDeep(out[k], v) : v;
|
|
10
|
+
}
|
|
11
|
+
return out as T;
|
|
12
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Lecture par path pointé ('hero.title', 'portes.0.label'). */
|
|
2
|
+
export function getByPath(obj: unknown, path: string): unknown {
|
|
3
|
+
if (!path) return obj;
|
|
4
|
+
let cur: any = obj;
|
|
5
|
+
for (const p of path.split(".")) {
|
|
6
|
+
if (cur == null) return undefined;
|
|
7
|
+
cur = cur[p];
|
|
8
|
+
}
|
|
9
|
+
return cur;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Écriture immutable : clone uniquement le chemin touché, supporte les indices de tableaux. */
|
|
13
|
+
export function setByPath<T>(obj: T, path: string, value: unknown): T {
|
|
14
|
+
if (!path) return value as T;
|
|
15
|
+
const parts = path.split(".");
|
|
16
|
+
const root: any = Array.isArray(obj) ? [...(obj as any)] : { ...(obj as any) };
|
|
17
|
+
let cur: any = root;
|
|
18
|
+
// `as string` : l'index est toujours < parts.length, mais les consommateurs
|
|
19
|
+
// avec noUncheckedIndexedAccess voient string | undefined.
|
|
20
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
21
|
+
const k = parts[i] as string;
|
|
22
|
+
const next = cur[k];
|
|
23
|
+
cur[k] = Array.isArray(next) ? [...next] : { ...(next ?? {}) };
|
|
24
|
+
cur = cur[k];
|
|
25
|
+
}
|
|
26
|
+
cur[parts[parts.length - 1] as string] = value;
|
|
27
|
+
return root;
|
|
28
|
+
}
|