@tekkare/romulus 0.1.240 → 0.1.242
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/dist/module.json +1 -1
- package/dist/runtime/assets/styles/tokens.css +1 -1
- package/layer/app/app.config.ts +90 -0
- package/layer/app/assets/tekkare_logo.svg +20 -0
- package/layer/app/components/OipDirectusSources.vue +192 -0
- package/layer/app/components/OipDocumentPreview.vue +158 -0
- package/layer/app/components/OipMetadataEditDialog.vue +350 -0
- package/layer/app/components/OipNoteEditDialog.vue +382 -0
- package/layer/app/components/OipShell.vue +711 -0
- package/layer/app/components/OipSourceEditDialog.vue +419 -0
- package/layer/app/components/OipSourceGroups.vue +107 -0
- package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
- package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
- package/layer/app/components/panels/OipPanelNotes.vue +99 -0
- package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
- package/layer/app/components/panels/OipPanelSources.vue +361 -0
- package/layer/app/components/panels/OipPanelTopics.vue +339 -0
- package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
- package/layer/app/composables/useOipCrumbLabel.ts +11 -0
- package/layer/app/composables/useOipEsIndices.ts +57 -0
- package/layer/app/composables/useOipFavorites.ts +69 -0
- package/layer/app/composables/useOipNotes.ts +90 -0
- package/layer/app/composables/useOipNotify.ts +21 -0
- package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
- package/layer/app/layouts/anonymous.vue +6 -0
- package/layer/app/layouts/error.vue +38 -0
- package/layer/app/notes/categories.ts +46 -0
- package/layer/app/notes/types.ts +100 -0
- package/layer/app/plugins/oip-es-indices.client.ts +18 -0
- package/layer/app/types/metadata.ts +38 -0
- package/layer/app/types/oip.ts +60 -0
- package/layer/app/types/tableGroups.ts +42 -0
- package/layer/app/utils/oipRail.ts +82 -0
- package/layer/nuxt.config.ts +32 -0
- package/layer/server/api/oip/asset/[id].get.ts +89 -0
- package/layer/server/api/oip/documents.get.ts +92 -0
- package/layer/server/api/oip/es-indices.get.ts +21 -0
- package/layer/server/api/oip/favorites.get.ts +107 -0
- package/layer/server/api/oip/metadata.get.ts +109 -0
- package/layer/server/api/oip/metadata.patch.ts +163 -0
- package/layer/server/api/oip/note-options.get.ts +89 -0
- package/layer/server/api/oip/note.get.ts +95 -0
- package/layer/server/api/oip/notes.get.ts +173 -0
- package/layer/server/api/oip/notes.patch.ts +198 -0
- package/layer/server/api/oip/sources.patch.ts +145 -0
- package/layer/server/api/oip/sources.post.ts +88 -0
- package/layer/server/api/oip/table-groups.get.ts +145 -0
- package/layer/server/api/oip/tutorial-video.get.ts +44 -0
- package/layer/server/api/romulus/version.get.ts +4 -0
- package/layer/server/utils/oipAuth.ts +69 -0
- package/layer/server/utils/oipEsIndices.ts +99 -0
- package/layer/server/utils/oipGuards.ts +90 -0
- package/layer/server/utils/oipNoteMarkup.ts +197 -0
- package/package.json +5 -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.
|
|
3
|
+
"version": "0.1.242",
|
|
4
4
|
"description": "Romulus Design System - Nuxt module by Tekkare",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -16,13 +16,15 @@
|
|
|
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"
|
|
20
21
|
},
|
|
21
22
|
"main": "./dist/module.cjs",
|
|
22
23
|
"module": "./dist/module.mjs",
|
|
23
24
|
"types": "./dist/types.d.ts",
|
|
24
25
|
"files": [
|
|
25
|
-
"dist"
|
|
26
|
+
"dist",
|
|
27
|
+
"layer"
|
|
26
28
|
],
|
|
27
29
|
"scripts": {
|
|
28
30
|
"build": "nuxt-module-build build",
|