@tekkare/romulus 0.1.241 → 0.1.243

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 (64) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/app.config.ts +90 -0
  3. package/layer/app/assets/tekkare_logo.svg +20 -0
  4. package/layer/app/components/AppUpdateToast.vue +157 -0
  5. package/layer/app/components/OipDirectusSources.vue +192 -0
  6. package/layer/app/components/OipDocumentPreview.vue +158 -0
  7. package/layer/app/components/OipMetadataEditDialog.vue +350 -0
  8. package/layer/app/components/OipNoteEditDialog.vue +382 -0
  9. package/layer/app/components/OipShell.vue +713 -0
  10. package/layer/app/components/OipSourceEditDialog.vue +419 -0
  11. package/layer/app/components/OipSourceGroups.vue +107 -0
  12. package/layer/app/components/auth/SplitLayout.vue +261 -0
  13. package/layer/app/components/layout/firstVisitVideo.vue +165 -0
  14. package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
  15. package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
  16. package/layer/app/components/panels/OipPanelNotes.vue +99 -0
  17. package/layer/app/components/panels/OipPanelProjects.vue +94 -0
  18. package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
  19. package/layer/app/components/panels/OipPanelSources.vue +361 -0
  20. package/layer/app/components/panels/OipPanelTopics.vue +339 -0
  21. package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
  22. package/layer/app/composables/useOipCrumbLabel.ts +11 -0
  23. package/layer/app/composables/useOipEsIndices.ts +57 -0
  24. package/layer/app/composables/useOipFavorites.ts +69 -0
  25. package/layer/app/composables/useOipNotes.ts +90 -0
  26. package/layer/app/composables/useOipNotify.ts +21 -0
  27. package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
  28. package/layer/app/composables/useViewEditing.ts +11 -0
  29. package/layer/app/layouts/anonymous.vue +6 -0
  30. package/layer/app/layouts/error.vue +38 -0
  31. package/layer/app/notes/categories.ts +46 -0
  32. package/layer/app/notes/types.ts +100 -0
  33. package/layer/app/plugins/oip-es-indices.client.ts +18 -0
  34. package/layer/app/types/metadata.ts +38 -0
  35. package/layer/app/types/oip.ts +60 -0
  36. package/layer/app/types/tableGroups.ts +42 -0
  37. package/layer/app/utils/countries.ts +30 -0
  38. package/layer/app/utils/exportPage.ts +418 -0
  39. package/layer/app/utils/headerTabs.ts +15 -0
  40. package/layer/app/utils/hybridRows.ts +22 -0
  41. package/layer/app/utils/oipRail.ts +82 -0
  42. package/layer/app/utils/postLoginRedirect.ts +58 -0
  43. package/layer/app/utils/valueTable.ts +126 -0
  44. package/layer/nuxt.config.ts +32 -0
  45. package/layer/server/api/oip/asset/[id].get.ts +89 -0
  46. package/layer/server/api/oip/documents.get.ts +92 -0
  47. package/layer/server/api/oip/es-indices.get.ts +21 -0
  48. package/layer/server/api/oip/favorites.get.ts +107 -0
  49. package/layer/server/api/oip/metadata.get.ts +109 -0
  50. package/layer/server/api/oip/metadata.patch.ts +163 -0
  51. package/layer/server/api/oip/note-options.get.ts +89 -0
  52. package/layer/server/api/oip/note.get.ts +95 -0
  53. package/layer/server/api/oip/notes.get.ts +173 -0
  54. package/layer/server/api/oip/notes.patch.ts +198 -0
  55. package/layer/server/api/oip/sources.patch.ts +145 -0
  56. package/layer/server/api/oip/sources.post.ts +88 -0
  57. package/layer/server/api/oip/table-groups.get.ts +145 -0
  58. package/layer/server/api/oip/tutorial-video.get.ts +44 -0
  59. package/layer/server/api/romulus/version.get.ts +4 -0
  60. package/layer/server/utils/oipAuth.ts +69 -0
  61. package/layer/server/utils/oipEsIndices.ts +99 -0
  62. package/layer/server/utils/oipGuards.ts +90 -0
  63. package/layer/server/utils/oipNoteMarkup.ts +197 -0
  64. package/package.json +6 -3
@@ -0,0 +1,69 @@
1
+ import type { H3Event } from "h3";
2
+ import { getCookie, parseCookies } from "h3";
3
+
4
+ // Identification de l'appelant pour les routes /api/oip/* du layer. Prefixees
5
+ // `oip` : les apps ont leurs propres helpers (getAccessToken, requireSession)
6
+ // et les utils serveur sont auto-importes, deux noms identiques se
7
+ // masqueraient.
8
+
9
+ /**
10
+ * Jeton d'acces de la session (FusionAuth via @sidebase/nuxt-auth).
11
+ *
12
+ * getServerSession d'abord ; a defaut, decodage du cookie de session. Son nom
13
+ * est propre a chaque app (`france-drugs.session-token`...) : il se regle par
14
+ * `runtimeConfig.oipSessionCookie`, sinon on prend le premier cookie qui finit
15
+ * par `.session-token`.
16
+ */
17
+ export async function getOipAccessToken(event: H3Event): Promise<string | null> {
18
+ try {
19
+ const { getServerSession } = await import("#auth");
20
+ const session = await getServerSession(event);
21
+ if ((session as any)?.accessToken) return (session as any).accessToken;
22
+ } catch {
23
+ // getServerSession indisponible : on passe au decodage du cookie.
24
+ }
25
+
26
+ try {
27
+ const { decode } = await import("next-auth/jwt");
28
+ const secret = process.env.NEXTAUTH_SECRET || process.env.AUTH_SECRET;
29
+ const name =
30
+ (useRuntimeConfig().oipSessionCookie as string | undefined) ||
31
+ Object.keys(parseCookies(event)).find((key) => key.endsWith(".session-token"));
32
+ const sessionToken = name ? getCookie(event, name) : undefined;
33
+ if (sessionToken && secret) {
34
+ const decoded = await decode({ token: sessionToken, secret });
35
+ return (decoded?.accessToken as string) || null;
36
+ }
37
+ } catch {
38
+ // Decodage impossible : pas de session.
39
+ }
40
+
41
+ return null;
42
+ }
43
+
44
+ /** Une session signee existe, sans demander a la Virtual Lab qui elle est. */
45
+ export async function requireOipSession(event: H3Event): Promise<string> {
46
+ const token = await getOipAccessToken(event);
47
+ if (!token) throw createError({ status: 401, message: "Authentication required" });
48
+ return token;
49
+ }
50
+
51
+ /**
52
+ * L'identifiant de l'appelant, demande a la Virtual Lab API avec son propre
53
+ * jeton, jamais lu dans la requete : personne ne lit la liste d'un autre.
54
+ */
55
+ export async function requireOipUserId(event: H3Event): Promise<string> {
56
+ const token = await requireOipSession(event);
57
+ const base = useRuntimeConfig().public.apiBase as string;
58
+ let me: { user?: { userId?: string } };
59
+ try {
60
+ me = await $fetch<{ user?: { userId?: string } }>(`${base}/me/summary`, {
61
+ headers: { Authorization: `Bearer ${token}` },
62
+ });
63
+ } catch {
64
+ throw createError({ status: 403, message: "Could not identify the caller" });
65
+ }
66
+ const userId = me?.user?.userId;
67
+ if (!userId) throw createError({ status: 403, message: "Could not identify the caller" });
68
+ return userId;
69
+ }
@@ -0,0 +1,99 @@
1
+ // Registre des index Elasticsearch, lu dans la collection Directus
2
+ // `ElasticIndices`. Une app designe un index par l'`id` Directus de sa ligne :
3
+ // il ne change jamais, alors que le nom physique suit les versions (`_v2`),
4
+ // les alias et la bascule staging. La resolution se fait ici, cote serveur,
5
+ // au moment d'interroger le cluster.
6
+
7
+ export interface OipEsIndexRow {
8
+ id: string;
9
+ canonical_name: string;
10
+ physical_index: string;
11
+ staging_physical: string | null;
12
+ status: string | null;
13
+ }
14
+
15
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
+
17
+ /** Statuts qui ne doivent plus etre interroges. */
18
+ const RETIRED = new Set(["candidate-for-deletion", "orphan-staging"]);
19
+
20
+ /** Cinq minutes : une bascule de version arrive vite sans marteler Directus. */
21
+ const TTL_MS = 5 * 60 * 1000;
22
+
23
+ let cache: { at: number; byId: Map<string, OipEsIndexRow>; byName: Map<string, OipEsIndexRow> } | null = null;
24
+ let inflight: Promise<void> | null = null;
25
+
26
+ export const isOipEsIndexId = (ref: string) => UUID.test(ref);
27
+
28
+ async function load() {
29
+ const config = useRuntimeConfig();
30
+ const url = ((config.oipEsIndicesUrl as string) || "").replace(/\/+$/, "");
31
+ const token = config.oipEsIndicesToken as string;
32
+ if (!url || !token) {
33
+ throw createError({
34
+ status: 503,
35
+ message: "Elastic indices registry is not configured: set NUXT_OIP_ES_INDICES_URL and NUXT_OIP_ES_INDICES_TOKEN",
36
+ });
37
+ }
38
+ const response = await $fetch<{ data: OipEsIndexRow[] }>(`${url}/items/ElasticIndices`, {
39
+ headers: { Authorization: `Bearer ${token}` },
40
+ query: { limit: -1, fields: "id,canonical_name,physical_index,staging_physical,status" },
41
+ });
42
+ const rows = response.data ?? [];
43
+ const byId = new Map(rows.map((row) => [row.id, row]));
44
+ const byName = new Map<string, OipEsIndexRow>();
45
+ for (const row of rows) {
46
+ byName.set(row.physical_index, row);
47
+ byName.set(row.canonical_name, row);
48
+ }
49
+ cache = { at: Date.now(), byId, byName };
50
+ }
51
+
52
+ /**
53
+ * Le registre, recharge au plus toutes les cinq minutes. Si Directus ne
54
+ * repond pas, l'ancien registre continue de servir : une panne du CMS ne doit
55
+ * pas couper les donnees.
56
+ */
57
+ export async function oipEsIndexRegistry() {
58
+ if (cache && Date.now() - cache.at < TTL_MS) return cache;
59
+ inflight ??= load()
60
+ .catch((error) => {
61
+ if (!cache) throw error;
62
+ })
63
+ .finally(() => {
64
+ inflight = null;
65
+ });
66
+ await inflight;
67
+ return cache!;
68
+ }
69
+
70
+ /** La ligne d'un index, par id Directus ou par nom (canonique ou physique). */
71
+ export async function findOipEsIndex(ref: string): Promise<OipEsIndexRow | undefined> {
72
+ const registry = await oipEsIndexRegistry();
73
+ return isOipEsIndexId(ref) ? registry.byId.get(ref) : registry.byName.get(ref);
74
+ }
75
+
76
+ /**
77
+ * Le nom a interroger pour une reference : l'index physique, ou sa copie de
78
+ * staging quand `runtimeConfig.oipEsIndicesStaging` est vrai et qu'elle
79
+ * existe. Un id inconnu ou un index retire est refuse ; un nom absent du
80
+ * registre passe tel quel (index pas encore declares dans Directus).
81
+ */
82
+ export async function resolveOipEsIndex(ref: string): Promise<string> {
83
+ const row = await findOipEsIndex(ref);
84
+ if (!row) {
85
+ if (isOipEsIndexId(ref)) throw createError({ status: 400, message: `Unknown index id: ${ref}` });
86
+ return ref;
87
+ }
88
+ if (row.status && RETIRED.has(row.status)) {
89
+ throw createError({ status: 410, message: `Index retired: ${row.canonical_name}` });
90
+ }
91
+ const staging = !!useRuntimeConfig().oipEsIndicesStaging;
92
+ return (staging && row.staging_physical) || row.physical_index;
93
+ }
94
+
95
+ /** Plusieurs references separees par des virgules, comme l'URL d'Elasticsearch. */
96
+ export async function resolveOipEsIndexList(refs: string): Promise<string> {
97
+ const names = await Promise.all(refs.split(",").map((ref) => resolveOipEsIndex(ref.trim())));
98
+ return names.join(",");
99
+ }
@@ -0,0 +1,90 @@
1
+ import type { H3Event } from "h3";
2
+ import type { z } from "zod";
3
+ import { getOipAccessToken } from "./oipAuth";
4
+
5
+ // Garde-fous partages par les routes d'ecriture /api/oip/*. Prefixes `oip`
6
+ // pour ne pas masquer les helpers des apps (utils serveur auto-importes).
7
+
8
+ /**
9
+ * Le droit d'administration, celui de la console d'OIP 7 (`/superadmin/me`),
10
+ * demande avec le jeton de l'utilisateur et revalide a chaque requete :
11
+ * cacher un crayon dans l'interface ne protege rien.
12
+ *
13
+ * L'URL de la Virtual Lab vient de `runtimeConfig.oipVirtualLabApiUrl`, sinon
14
+ * de l'env VIRTUAL_LAB_API_URL.
15
+ */
16
+ export async function assertOipSuperAdmin(event: H3Event) {
17
+ const token = await getOipAccessToken(event);
18
+ if (!token) throw createError({ status: 401, message: "Not authenticated" });
19
+
20
+ const apiUrl = (useRuntimeConfig().oipVirtualLabApiUrl as string) || process.env.VIRTUAL_LAB_API_URL;
21
+ if (!apiUrl) throw createError({ status: 500, message: "VIRTUAL_LAB_API_URL is not set" });
22
+
23
+ let isSuperAdmin = false;
24
+ try {
25
+ const me = await $fetch<{ isSuperAdmin?: boolean }>(`${apiUrl}/superadmin/me`, {
26
+ headers: { Authorization: `Bearer ${token}` },
27
+ });
28
+ isSuperAdmin = !!me?.isSuperAdmin;
29
+ } catch {
30
+ isSuperAdmin = false;
31
+ }
32
+ if (!isSuperAdmin) throw createError({ status: 403, message: "Superadmin required" });
33
+ }
34
+
35
+ /**
36
+ * Les `prod_url` de l'app dans le portail OIP 7 (fiche OipApplication) : la
37
+ * collection est commune a tout le portail, une app s'y reconnait par la.
38
+ * `runtimeConfig.oipAppUrl`, sinon l'env NOTES_APP_URL ; plusieurs URL
39
+ * separees par des virgules, la premiere designe la fiche corrigee. Pas de
40
+ * valeur par defaut : une app mal reglee lirait les notes d'une autre.
41
+ */
42
+ export function oipAppUrls(): string[] {
43
+ const raw = (useRuntimeConfig().oipAppUrl as string) || process.env.NOTES_APP_URL || "";
44
+ const urls = raw
45
+ .split(",")
46
+ .map((url) => url.trim().replace(/\/+$/, ""))
47
+ .filter(Boolean);
48
+ if (!urls.length) {
49
+ throw createError({ status: 500, message: "App URL is not set: NUXT_OIP_APP_URL or NOTES_APP_URL" });
50
+ }
51
+ return urls;
52
+ }
53
+
54
+ const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
55
+
56
+ export function oipCheckRateLimit(key: string, maxAttempts: number, windowMs: number): void {
57
+ const now = Date.now();
58
+ const entry = rateLimitMap.get(key);
59
+ if (entry && now < entry.resetAt) {
60
+ if (entry.count >= maxAttempts) {
61
+ throw createError({ status: 429, message: "Too many requests. Please try again later." });
62
+ }
63
+ entry.count++;
64
+ } else {
65
+ rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
66
+ }
67
+ if (rateLimitMap.size > 10000) {
68
+ for (const [k, v] of rateLimitMap) {
69
+ if (now > v.resetAt) rateLimitMap.delete(k);
70
+ }
71
+ }
72
+ }
73
+
74
+ /** Dernier saut de X-Forwarded-For : le seul ecrit par notre ingress. */
75
+ export function oipClientIp(event: H3Event): string {
76
+ const hops = getRequestHeader(event, "x-forwarded-for")
77
+ ?.split(",")
78
+ .map((h) => h.trim())
79
+ .filter(Boolean);
80
+ return hops?.pop() || getRequestIP(event) || "unknown";
81
+ }
82
+
83
+ export async function oipValidateBody<T>(event: H3Event, schema: z.ZodSchema<T>): Promise<T> {
84
+ const body = await readBody(event);
85
+ const result = schema.safeParse(body);
86
+ if (!result.success) {
87
+ throw createError({ status: 400, message: result.error.issues[0]?.message || "Invalid input" });
88
+ }
89
+ return result.data;
90
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Le balisage des notes Directus, converti en blocs pour RmNotes.
3
+ *
4
+ * Directus ne stocke qu'un texte. Le panneau, lui, rend des blocs typés : un
5
+ * encadre, un exemple, une liste, une liste de definitions. Plutot que de
6
+ * demander un JSON a l'editeur, le texte porte des marqueurs que ce module
7
+ * traduit.
8
+ *
9
+ * Un marqueur ouvre le paragraphe et vaut jusqu'a la ligne vide :
10
+ *
11
+ * [info] Le taux reel inclut la prise en charge ALD.
12
+ * [warning] Les acces precoces n'ont pas d'ASMR.
13
+ * [example] Y = ((94% - 65%) / (100% - 65%))
14
+ *
15
+ * Le fond et l'icone viennent du ton : bleu et « i » pour [info], ambre et
16
+ * triangle pour [warning]. La couleur ne dit jamais la nature toute seule.
17
+ *
18
+ * Tout ce qui n'est pas reconnu reste du paragraphe : une note mal balisee
19
+ * s'affiche en texte, jamais en code.
20
+ */
21
+ import type { NoteBlock, NoteSection } from "../../app/notes/types";
22
+
23
+ /** Marqueurs d'encadre, avec leurs alias. Le ton decide du fond et de l'icone. */
24
+ const CALLOUTS: Record<string, "info" | "warning"> = {
25
+ info: "info",
26
+ tips: "info",
27
+ astuce: "info",
28
+ warning: "warning",
29
+ attention: "warning",
30
+ limite: "warning",
31
+ };
32
+
33
+ const MARKER_LINE = /^\[(info|tips|astuce|warning|attention|limite|example|exemple)\]\s*(.*)$/i;
34
+ const LINK_TAG = /^<(?:link|lien)\s+to="([^"]+)"\s*>([\s\S]*?)<\/(?:link|lien)>$/i;
35
+ const SECTION_LINE = /^##\s+(.*)$/;
36
+ const BULLET_LINE = /^[•*\-–]\s+(.*)$/;
37
+ const TERM_LINE = /^\*\*(.+?)\*\*\s*[::]\s*(.*)$/;
38
+
39
+ /** Un morceau du texte : soit un titre de section, soit un bloc rendu. */
40
+ type Piece = { section: string } | { block: NoteBlock };
41
+
42
+ /**
43
+ * Le texte d'une note, decoupe en sections.
44
+ *
45
+ * Les blocs arrivent dans l'ordre d'ecriture ; un `## Titre` ouvre une
46
+ * section, et ce qui precede le premier titre forme la section d'entete.
47
+ */
48
+ export function oipParseNoteContent(raw: string): NoteSection[] {
49
+ return fold(parseLines((raw ?? "").replace(/\r\n?/g, "\n")));
50
+ }
51
+
52
+ /** Le texte ligne a ligne : marqueurs, titres de section, listes, renvois. */
53
+ function parseLines(text: string): Piece[] {
54
+ const pieces: Piece[] = [];
55
+ let list: string[] = [];
56
+ let terms: { term: string; def?: string }[] = [];
57
+ let paragraph: string[] = [];
58
+ /** Marqueur ouvert : il tient jusqu'a la ligne vide, ou jusqu'a un autre bloc. */
59
+ let marker: string | null = null;
60
+
61
+ const flush = () => {
62
+ if (list.length) pieces.push({ block: { type: "list", items: list } });
63
+ if (terms.length) pieces.push({ block: { type: "terms", items: terms } });
64
+ if (paragraph.length) pieces.push({ block: paragraphBlock(paragraph.join(" "), marker) });
65
+ list = [];
66
+ terms = [];
67
+ paragraph = [];
68
+ marker = null;
69
+ };
70
+
71
+ for (const rawLine of text.split("\n")) {
72
+ const line = rawLine.trim();
73
+ if (!line) {
74
+ flush();
75
+ continue;
76
+ }
77
+
78
+ // Un marqueur ferme ce qui precede : deux encadres de suite restent deux
79
+ // encadres, meme colles.
80
+ const mark = line.match(MARKER_LINE);
81
+ if (mark) {
82
+ flush();
83
+ marker = mark[1]!.toLowerCase();
84
+ if (mark[2]!.trim()) paragraph.push(mark[2]!.trim());
85
+ continue;
86
+ }
87
+
88
+ const section = line.match(SECTION_LINE);
89
+ if (section) {
90
+ flush();
91
+ pieces.push({ section: section[1]!.trim() });
92
+ continue;
93
+ }
94
+
95
+ const link = line.match(LINK_TAG);
96
+ if (link) {
97
+ flush();
98
+ pieces.push({ block: { type: "link", to: link[1]!, label: link[2]!.trim() } });
99
+ continue;
100
+ }
101
+
102
+ const bullet = line.match(BULLET_LINE);
103
+ if (bullet) {
104
+ if (paragraph.length || terms.length) flush();
105
+ list.push(bullet[1]!.trim());
106
+ continue;
107
+ }
108
+
109
+ // Une definition ne se reconnait qu'en debut de ligne : « **Terme** : sens ».
110
+ const term = line.match(TERM_LINE);
111
+ if (term) {
112
+ if (paragraph.length || list.length) flush();
113
+ terms.push({ term: term[1]!.trim(), ...(term[2]!.trim() ? { def: term[2]!.trim() } : {}) });
114
+ continue;
115
+ }
116
+
117
+ if (list.length || terms.length) flush();
118
+ paragraph.push(line);
119
+ }
120
+
121
+ flush();
122
+ return pieces;
123
+ }
124
+
125
+ /** Le paragraphe courant, selon le marqueur qui l'a ouvert. */
126
+ function paragraphBlock(text: string, marker: string | null): NoteBlock {
127
+ if (!marker) return { type: "text", text };
128
+ if (marker === "example" || marker === "exemple") return { type: "example", text };
129
+ return { type: "callout", tone: CALLOUTS[marker] ?? "info", text };
130
+ }
131
+
132
+ /** Les morceaux regroupes par section. Une section vide ne sort pas. */
133
+ function fold(pieces: Piece[]): NoteSection[] {
134
+ const sections: NoteSection[] = [];
135
+ let current: NoteSection = { blocks: [] };
136
+
137
+ for (const piece of pieces) {
138
+ if ("section" in piece) {
139
+ if (current.blocks.length) sections.push(current);
140
+ current = { title: piece.section, blocks: [] };
141
+ continue;
142
+ }
143
+ current.blocks.push(piece.block);
144
+ }
145
+ if (current.blocks.length) sections.push(current);
146
+
147
+ return sections;
148
+ }
149
+
150
+ /**
151
+ * Le chemin inverse : des blocs vers le texte balise que Directus stocke.
152
+ *
153
+ * L'editeur du panneau travaille sur des blocs ; le CMS, lui, garde un texte,
154
+ * relisible et corrigeable dans son propre editeur. Les deux fonctions vont
155
+ * ensemble : ce que `serializeNoteContent` ecrit, `parseNoteContent` doit le
156
+ * relire a l'identique, sans quoi une note corrigee depuis l'app changerait de
157
+ * forme au rechargement.
158
+ *
159
+ * Un bloc par paragraphe, separes par une ligne vide : c'est ce qui ferme un
160
+ * marqueur ouvert, et ce qui empeche deux encadres de se fondre en un seul.
161
+ */
162
+ export function oipSerializeNoteContent(sections: NoteSection[]): string {
163
+ const parts: string[] = [];
164
+
165
+ for (const section of sections ?? []) {
166
+ if (section.title?.trim()) parts.push(`## ${section.title.trim()}`);
167
+ for (const block of section.blocks ?? []) {
168
+ const text = serializeBlock(block);
169
+ if (text) parts.push(text);
170
+ }
171
+ }
172
+
173
+ return parts.join("\n\n");
174
+ }
175
+
176
+ function serializeBlock(block: NoteBlock): string {
177
+ switch (block.type) {
178
+ case "text":
179
+ return block.text.trim();
180
+ // Les entrees d'une liste se suivent sans ligne vide : une coupure ferait
181
+ // deux listes.
182
+ case "list":
183
+ return block.items.map((item) => `• ${item.trim()}`).join("\n");
184
+ case "terms":
185
+ return block.items
186
+ .map((item) => `**${item.term.trim()}** : ${(item.def ?? "").trim()}`)
187
+ .join("\n");
188
+ case "callout":
189
+ return `[${block.tone === "warning" ? "warning" : "info"}] ${block.text.trim()}`;
190
+ case "example":
191
+ return `[example] ${block.text.trim()}`;
192
+ case "link":
193
+ return `<link to="${block.to.trim()}">${block.label.trim()}</link>`;
194
+ default:
195
+ return "";
196
+ }
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekkare/romulus",
3
- "version": "0.1.241",
3
+ "version": "0.1.243",
4
4
  "description": "Romulus Design System - Nuxt module by Tekkare",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,13 +16,16 @@
16
16
  "./utils/*": {
17
17
  "types": "./dist/runtime/utils/*.d.ts",
18
18
  "import": "./dist/runtime/utils/*.js"
19
- }
19
+ },
20
+ "./layer": "./layer/nuxt.config.ts",
21
+ "./layer-utils/*": "./layer/app/utils/*.ts"
20
22
  },
21
23
  "main": "./dist/module.cjs",
22
24
  "module": "./dist/module.mjs",
23
25
  "types": "./dist/types.d.ts",
24
26
  "files": [
25
- "dist"
27
+ "dist",
28
+ "layer"
26
29
  ],
27
30
  "scripts": {
28
31
  "build": "nuxt-module-build build",