@pixelersagency/okno-bridge 1.0.0-beta.5 → 1.0.0-beta.6

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 (3) hide show
  1. package/bin/okno.js +55 -0
  2. package/package.json +14 -3
  3. package/src/content.js +170 -0
package/bin/okno.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * okno — outils du front pour un site Okno.
4
+ *
5
+ * npx okno types [--url https://cms.example.com] [--out src/okno.d.ts]
6
+ * npx okno pull [--url https://cms.example.com] [--out .okno/content.json]
7
+ *
8
+ * L'adresse du CMS et le jeton se lisent aussi dans OKNO_URL et OKNO_TOKEN.
9
+ */
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { exportUrl, loadOknoContent } from '../src/content.js';
13
+
14
+ const [ command, ...rest ] = process.argv.slice( 2 );
15
+ const args = {};
16
+ for ( let i = 0; i < rest.length; i++ ) {
17
+ if ( rest[ i ].startsWith( '--' ) ) {
18
+ const [ key, inline ] = rest[ i ].slice( 2 ).split( '=' );
19
+ args[ key ] = inline ?? rest[ ++i ];
20
+ }
21
+ }
22
+
23
+ const url = args.url || process.env.OKNO_URL;
24
+ const token = args.token || process.env.OKNO_TOKEN || '';
25
+
26
+ async function types() {
27
+ const endpoint = exportUrl( url ).replace( /\/export$/, '/types' );
28
+ const headers = token ? { 'X-Okno-Token': token } : {};
29
+ const response = await fetch( endpoint, { headers } );
30
+ if ( ! response.ok ) {
31
+ throw new Error( 'HTTP ' + response.status + ' on ' + endpoint );
32
+ }
33
+ const out = args.out || 'src/okno.d.ts';
34
+ await fs.mkdir( path.dirname( out ), { recursive: true } );
35
+ await fs.writeFile( out, await response.text() );
36
+ console.log( 'okno: types written to ' + out );
37
+ }
38
+
39
+ async function pull() {
40
+ const out = args.out || '.okno/content.json';
41
+ const { data, source } = await loadOknoContent( { url, token, snapshot: out, fallback: 'error' } );
42
+ console.log( 'okno: ' + data.items.length + ' items (' + source + ') in ' + out );
43
+ }
44
+
45
+ const commands = { types, pull };
46
+
47
+ if ( ! commands[ command ] ) {
48
+ console.log( 'Usage: okno <types|pull> [--url https://cms.example.com] [--out file] [--token …]' );
49
+ process.exit( command ? 1 : 0 );
50
+ }
51
+
52
+ commands[ command ]().catch( ( error ) => {
53
+ console.error( 'okno: ' + error.message );
54
+ process.exit( 1 );
55
+ } );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelersagency/okno-bridge",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.6",
4
4
  "description": "Pont postMessage de l'éditeur visuel Okno : rend n'importe quel site (React, Next.js, Remix, Vite, Nuxt, SvelteKit, Astro, HTML) éditable depuis WordPress. Zéro dépendance, inactif hors de l'éditeur.",
5
5
  "keywords": [
6
6
  "wordpress",
@@ -28,14 +28,19 @@
28
28
  "./react": {
29
29
  "import": "./src/react.js"
30
30
  },
31
- "./standalone": "./dist/okno-bridge.js"
31
+ "./standalone": "./dist/okno-bridge.js",
32
+ "./content": {
33
+ "import": "./src/content.js"
34
+ }
32
35
  },
33
36
  "scripts": {
34
- "build": "node build.js"
37
+ "build": "node build.js",
38
+ "test": "node --test"
35
39
  },
36
40
  "files": [
37
41
  "src",
38
42
  "dist",
43
+ "bin",
39
44
  "README.md"
40
45
  ],
41
46
  "sideEffects": false,
@@ -52,5 +57,11 @@
52
57
  "react": {
53
58
  "optional": true
54
59
  }
60
+ },
61
+ "bin": {
62
+ "okno": "./bin/okno.js"
63
+ },
64
+ "engines": {
65
+ "node": ">=18"
55
66
  }
56
67
  }
package/src/content.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Client de l'API de contenu Okno, pour le build du front (Node 18+).
3
+ *
4
+ * import { loadOknoContent, indexOknoContent } from '@pixelersagency/okno-bridge/content';
5
+ *
6
+ * const { data } = await loadOknoContent( { url: 'https://cms.example.com' } );
7
+ * const content = indexOknoContent( data );
8
+ * content.byPath( '/services/audit' );
9
+ *
10
+ * Une seule requête (/wp-json/okno/v1/export), avec reprises. Chaque réponse
11
+ * réussie est gardée dans un instantané local : si WordPress ne répond pas
12
+ * (base saturée, maintenance), le build continue avec le dernier contenu
13
+ * connu au lieu d'échouer, et le signale. Un contenu inchangé renvoie un 304 :
14
+ * rien n'est retéléchargé.
15
+ *
16
+ * Le contenu vit dans WordPress et nulle part ailleurs : pas de JSON local qui
17
+ * le double, l'instantané n'est qu'un cache.
18
+ */
19
+
20
+ const DEFAULT_SNAPSHOT = '.okno/content.json';
21
+
22
+ /**
23
+ * URL de l'export depuis l'adresse du CMS (ou l'URL complète de l'endpoint).
24
+ *
25
+ * @param {string} url
26
+ * @returns {string}
27
+ */
28
+ export function exportUrl( url ) {
29
+ if ( ! url ) {
30
+ throw new Error( 'Okno: missing CMS url (option `url` or OKNO_URL).' );
31
+ }
32
+ const clean = String( url ).replace( /\/+$/, '' );
33
+ if ( clean.includes( '/wp-json/' ) || clean.includes( 'rest_route=' ) ) {
34
+ return clean;
35
+ }
36
+ return clean + '/wp-json/okno/v1/export';
37
+ }
38
+
39
+ /**
40
+ * Charge tout le contenu publié.
41
+ *
42
+ * @param {object} [options]
43
+ * @param {string} [options.url] Adresse du CMS. Défaut : process.env.OKNO_URL.
44
+ * @param {string} [options.token] Jeton (accès restreint). Défaut : process.env.OKNO_TOKEN.
45
+ * @param {string|false} [options.snapshot] Fichier d'instantané, false pour s'en passer.
46
+ * @param {'snapshot'|'error'} [options.fallback] Si WordPress ne répond pas : instantané (défaut) ou échec.
47
+ * @param {number} [options.retries] Tentatives (défaut 3).
48
+ * @param {number} [options.timeout] Délai par tentative en ms (défaut 30 000).
49
+ * @param {Function} [options.fetch] fetch à utiliser (tests).
50
+ * @param {{warn:Function}} [options.logger]
51
+ * @returns {Promise<{data: object, source: 'network'|'unchanged'|'snapshot', stale: boolean}>}
52
+ */
53
+ export async function loadOknoContent( options = {} ) {
54
+ const env = typeof process !== 'undefined' ? process.env : {};
55
+ const url = exportUrl( options.url || env.OKNO_URL );
56
+ const token = options.token ?? env.OKNO_TOKEN ?? '';
57
+ const snapshotPath = options.snapshot === false ? null : options.snapshot || DEFAULT_SNAPSHOT;
58
+ const fallback = options.fallback || 'snapshot';
59
+ const retries = Math.max( 1, options.retries ?? 3 );
60
+ const timeout = options.timeout ?? 30000;
61
+ const doFetch = options.fetch || globalThis.fetch;
62
+ const logger = options.logger || console;
63
+
64
+ const snapshot = snapshotPath ? await readSnapshot( snapshotPath ) : null;
65
+
66
+ const headers = { Accept: 'application/json' };
67
+ if ( token ) {
68
+ headers[ 'X-Okno-Token' ] = token;
69
+ }
70
+ if ( snapshot && snapshot.version ) {
71
+ headers[ 'If-None-Match' ] = '"' + snapshot.version + '"';
72
+ }
73
+
74
+ let lastError = null;
75
+ for ( let attempt = 1; attempt <= retries; attempt++ ) {
76
+ try {
77
+ const response = await doFetch( url, {
78
+ headers,
79
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout( timeout ) : undefined,
80
+ } );
81
+
82
+ if ( response.status === 304 && snapshot ) {
83
+ return { data: snapshot, source: 'unchanged', stale: false };
84
+ }
85
+ if ( response.status === 401 || response.status === 403 ) {
86
+ // Inutile d'insister : le jeton est faux ou absent.
87
+ throw Object.assign( new Error( 'Okno: access refused (' + response.status + '). Check OKNO_TOKEN.' ), { fatal: true } );
88
+ }
89
+ if ( ! response.ok ) {
90
+ throw new Error( 'Okno: HTTP ' + response.status + ' on ' + url );
91
+ }
92
+
93
+ const data = await response.json();
94
+ if ( ! data || ! Array.isArray( data.items ) ) {
95
+ throw new Error( 'Okno: unexpected response from ' + url + ' (is Okno 1.0.0-beta.6+ active?)' );
96
+ }
97
+ if ( snapshotPath ) {
98
+ await writeSnapshot( snapshotPath, data );
99
+ }
100
+ return { data, source: 'network', stale: false };
101
+ } catch ( error ) {
102
+ lastError = error;
103
+ if ( error.fatal ) {
104
+ break;
105
+ }
106
+ if ( attempt < retries ) {
107
+ await sleep( 1000 * 2 ** ( attempt - 1 ) );
108
+ }
109
+ }
110
+ }
111
+
112
+ if ( snapshot && fallback === 'snapshot' && ! lastError?.fatal ) {
113
+ logger.warn(
114
+ '[okno] WordPress did not answer (' + ( lastError && lastError.message ) + '). ' +
115
+ 'Using the snapshot from ' + ( snapshot.generated || 'an earlier build' ) + '.'
116
+ );
117
+ return { data: snapshot, source: 'snapshot', stale: true };
118
+ }
119
+ throw lastError || new Error( 'Okno: no content.' );
120
+ }
121
+
122
+ /**
123
+ * Index de recherche sur un export.
124
+ *
125
+ * @param {{items: Array<object>}} data
126
+ */
127
+ export function indexOknoContent( data ) {
128
+ const items = ( data && data.items ) || [];
129
+ const byId = new Map();
130
+ const byPath = new Map();
131
+ for ( const item of items ) {
132
+ byId.set( item.id, item );
133
+ byPath.set( normalizePath( item.path ), item );
134
+ }
135
+ return {
136
+ items,
137
+ byId: ( id ) => byId.get( Number( id ) ) || null,
138
+ byPath: ( path ) => byPath.get( normalizePath( path ) ) || null,
139
+ byType: ( type ) => items.filter( ( item ) => item.type === type ),
140
+ bySlug: ( type, slug ) => items.find( ( item ) => item.type === type && item.slug === slug ) || null,
141
+ };
142
+ }
143
+
144
+ function normalizePath( path ) {
145
+ const clean = '/' + String( path || '' ).split( /[?#]/ )[ 0 ].replace( /^\/+|\/+$/g, '' );
146
+ return clean === '/' ? '/' : clean;
147
+ }
148
+
149
+ async function readSnapshot( file ) {
150
+ const fs = await import( 'node:fs/promises' );
151
+ try {
152
+ return JSON.parse( await fs.readFile( file, 'utf8' ) );
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ async function writeSnapshot( file, data ) {
159
+ const fs = await import( 'node:fs/promises' );
160
+ const path = await import( 'node:path' );
161
+ await fs.mkdir( path.dirname( file ), { recursive: true } );
162
+ // Écriture atomique : un build interrompu ne laisse pas d'instantané tronqué.
163
+ const tmp = file + '.' + process.pid + '.tmp';
164
+ await fs.writeFile( tmp, JSON.stringify( data ) );
165
+ await fs.rename( tmp, file );
166
+ }
167
+
168
+ function sleep( ms ) {
169
+ return new Promise( ( resolve ) => setTimeout( resolve, ms ) );
170
+ }