@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.
- package/bin/okno.js +55 -0
- package/package.json +14 -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.
|
|
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
|
+
}
|