Repository navigation
Expand file tree
/
Copy pathbuild.php
More file actions
393 lines (333 loc) · 12.1 KB
/
Copy pathbuild.php
File metadata and controls
393 lines (333 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
<?php
declare(strict_types=1);
/**
* Build the React application, inline all its assets into the HTML,
* assemble the PHP fragments from src/ around the inlined bundle,
* and write the result to dist/.
*
* Usage:
*
* php build.php dev build -> dist/index.php (incl. dev.php)
* php build.php release release -> dist/sifpress.php (no dev.php)
*
* The dev build embeds src/dev.php (?p=sifpress/dev&action=initData, an
* admin-gated demo-data seeder). The release build excludes the dev
* fragment entirely and strips its dispatch from router.php, so the
* artifact contains no trace of the dev endpoint.
*
* The generated file can be copied to:
*
* /
* /myapp/
* /tools/myapp/
*
* without rebuilding and WITHOUT any rewrite rules (.htaccess / Nginx
* try_files / etc.). All JavaScript and CSS is inlined, so the browser
* makes exactly one request.
*
* Source layout:
*
* src/env.php PHP version/extension/FTS5 requirements (assembled first)
* src/bootstrap.php constants + core helpers
* src/urlmode.php ?p= / clean-path URL resolution + pretty-mode links
* src/db.php SQLite open, pragmas, migration detection/runner, seeds
* src/migration.php ?p=sifpress/migration handler
* src/auth.php sessions, RBAC, page grants
* src/update.php ?p=sifpress/update version check / upgrade handler
* src/demo_page.php shared markdown-demo page (virtual + dev seed)
* src/api.php JSON API handler
* src/storage.php asset storage interface + filesystem backend
* src/asset.php binary asset/avatar/blob serving
* src/seo.php settings store + sitemap/robots + head meta injection
* src/spa.php SPA serving / meta injection
* src/embed.php EMBEDDED_HTML region (regenerated by this build)
* src/migrations.php MIGRATIONS region (SQL scripts embedded by this build)
* src/cli.php CLI: setup / migrate / status (php sifpress.php ...)
* src/dev.php dev-only ?p=sifpress/dev handler (dev builds only)
* src/router.php main router
*
* The admin UI uses pnpm (see admin_ui/package.json).
*/
require __DIR__ . '/build_common.php';
$root = __DIR__;
$app = $root . '/admin_ui';
$appDist = $app . '/dist';
$src = $root . '/src';
$outputDir = $root . '/dist';
$mode = $argv[1] ?? 'dev';
if ($mode === 'release' || $mode === 'rel') {
$isRelease = true;
} else {
$isRelease = false;
$mode = 'dev';
}
$output = $outputDir . ($isRelease ? '/sifpress.php' : '/index.php');
/**
* Assembly order of the src/ fragments. dev.php is dev-only and
* must sit before router.php, which dispatches to handle_dev().
*/
$parts = [
'env.php',
'bootstrap.php',
'urlmode.php',
'db.php',
'migration.php',
'auth.php',
'update.php',
'demo_page.php',
'api.php',
'storage.php',
'asset.php',
'sifront.php',
'seo.php',
'tracking.php',
'favicon.php',
'spa.php',
'embed.php',
'ui_sdk_serve.php',
'migrations.php',
'backup.php',
'cli.php',
];
if (!$isRelease) {
$parts[] = 'dev.php';
}
$parts[] = 'router.php';
if (!is_dir($app)) {
throw new RuntimeException('admin_ui directory not found');
}
if (!is_dir($src)) {
throw new RuntimeException('src directory not found');
}
foreach ($parts as $part) {
if (!is_file($src . '/' . $part)) {
throw new RuntimeException("Missing src fragment: $part");
}
}
$appBuild = $mode === 'release' || $mode === 'rel' ? 'pnpm run build:release' : 'pnpm run build:dev';
if (!is_dir($app . '/node_modules')) {
run('pnpm install', $app);
}
run($appBuild, $app);
/*
* Build the shared ui-sdk bundle. It embeds React, the common libs, and the
* ui-sdk API (markdown render included) into a single ES module that both the
* admin SPA and the sifront load via `?p=sifpress/asset/js/ui-sdk.mjs`.
*/
$uiSdkDir = $root . '/ui_sdk';
if (!is_dir($uiSdkDir . '/node_modules')) {
run('pnpm install', $uiSdkDir);
}
run($isRelease ? 'pnpm run build:release' : 'pnpm run build:dev', $uiSdkDir);
$uiSdkBundle = $uiSdkDir . '/dist/ui-sdk.mjs';
if (!is_file($uiSdkBundle)) {
throw new RuntimeException('ui-sdk bundle was not generated at ui_sdk/dist/ui-sdk.mjs');
}
$uiSdkChunks = [];
foreach (glob($uiSdkDir . '/dist/*.mjs') ?: [] as $chunkPath) {
$chunkJs = file_get_contents($chunkPath);
if ($chunkJs === false) {
throw new RuntimeException("Could not read $chunkPath");
}
$uiSdkChunks[basename($chunkPath)] = $chunkJs;
}
$uiSdkJs = $uiSdkChunks['ui-sdk.mjs'] ?? '';
if ($uiSdkJs === '') {
throw new RuntimeException('Could not read ui_sdk/dist/ui-sdk.mjs');
}
if (!is_dir($appDist)) {
throw new RuntimeException('Vite dist directory was not generated');
}
$html = file_get_contents($appDist . '/index.html');
if ($html === false) {
throw new RuntimeException('Could not read admin_ui/dist/index.html');
}
/*
* Normalize Vite's relative ./assets/... references. Inlining below
* only matches assets/..., so "./assets/foo.js" matches too, but keep
* the HTML tidy by canonicalizing the prefix.
*/
$html = preg_replace(
'#(?:\./)?assets/#',
'assets/',
$html
);
if ($html === null) {
throw new RuntimeException('Could not normalize asset URLs');
}
/*
* Inject the shared ui-sdk module script into <head> before the app bundle.
* Module scripts execute in document order after parsing, so this tag must
* come first in <head> for `window.SifpressUI` to exist before the app code
* references it. The tag has no asset/ src, so inline_assets() below leaves
* it as-is.
*/
$uiSdkScript = '<script type="module" src="?p=sifpress/asset/js/ui-sdk.mjs"></script>'
. '<script type="module" src="?p=sifpress/asset/js/ui-sdk-markdown.mjs"></script>';
$pos = strpos($html, '<head>');
if ($pos !== false) {
$html = substr_replace($html, '<head>' . $uiSdkScript, $pos, strlen('<head>'));
}
/*
* Inline all JS and CSS so the document is fully self-contained.
* The static title is stripped; PHP injects a route-aware title.
* Limit=1: only the single <head> title is a match target — the inlined
* JS can legitimately contain "<title" (e.g. the commonmark image input
* rule's `(?<title>…)` group) with no closing tag, which would otherwise
* make the regex scan the whole bundle and hit the PCRE backtrack limit.
*/
$html = inline_assets($html, $appDist, $mode === 'release' || $mode === 'rel' ? false : true);
$html = preg_replace('/<title[^>]*>.*?<\/title>/is', '', $html, 1);
if ($html === null) {
throw new RuntimeException('Could not strip the static title');
}
/*
* The HTML is injected into the PHP source as a single-quoted
* PHP string via var_export(), so arbitrary HTML is safe here.
*/
$htmlPhp = var_export($html, true);
/*
* Read migrations/*.sql and embed them as a PHP array constant. The
* SQL scripts are the authoring source of truth; the embedded region
* makes the single-file artifact self-contained at runtime. Keys are
* sorted by filename, so db_migrate() applies them in order.
*/
$migrationsDir = $root . '/migrations';
if (!is_dir($migrationsDir)) {
throw new RuntimeException('migrations directory not found');
}
$migrationFiles = glob($migrationsDir . '/*.sql');
if ($migrationFiles === false || $migrationFiles === []) {
throw new RuntimeException('No migration files found in migrations/');
}
sort($migrationFiles);
$migrations = [];
foreach ($migrationFiles as $file) {
$name = basename($file);
$content = file_get_contents($file);
if ($content === false) {
throw new RuntimeException("Could not read migration: $name");
}
$migrations[$name] = $content;
}
$migrationsPhp = 'const MIGRATIONS = ' . var_export($migrations, true) . ';';
/*
* Assemble the single-file artifact from the src/ fragments.
*/
$source = "<?php\ndeclare(strict_types=1);\n\n";
foreach ($parts as $part) {
$content = file_get_contents($src . '/' . $part);
if ($content === false) {
throw new RuntimeException("Could not read src fragment: $part");
}
if ($part === 'embed.php') {
/*
* Inject the fully inlined HTML into the delimited region.
* preg_replace_callback returns the literal replacement directly,
* so unlike a string replacement there is no $/backslash
* processing. This is also idempotent: it works whether the
* region currently holds the marker or an earlier build's HTML.
*/
$count = 0;
$content = preg_replace_callback(
'#// ___BEGIN_EMBEDDED___\n.*?// ___END_EMBEDDED___#s',
function () use ($htmlPhp): string {
return "// ___BEGIN_EMBEDDED___\nconst EMBEDDED_HTML = " .
$htmlPhp . ";\n// ___END_EMBEDDED___";
},
$content,
1,
$count
);
if ($count !== 1) {
throw new RuntimeException(
'Could not find the embedded HTML region (matched ' . $count . ')'
);
}
/*
* Embed the shared ui-sdk bundle the same way. var_export emits a
* single-quoted PHP string, so the JS (which contains both quote
* types and $/backslashes) is safe.
*/
$uiSdkChunksPhp = var_export($uiSdkChunks, true);
$uiSdkVersion = substr(md5(implode('', $uiSdkChunks)), 0, 12);
$count = 0;
$content = preg_replace_callback(
'#// ___BEGIN_UI_SDK___\n.*?// ___END_UI_SDK___#s',
function () use ($uiSdkChunksPhp, $uiSdkVersion): string {
return "// ___BEGIN_UI_SDK___\nconst UI_SDK_CHUNKS = " .
$uiSdkChunksPhp . ";\nconst UI_SDK_VERSION = " .
var_export($uiSdkVersion, true) . ";\n// ___END_UI_SDK___";
},
$content,
1,
$count
);
if ($count !== 1) {
throw new RuntimeException(
'Could not find the UI_SDK region (matched ' . $count . ')'
);
}
}
if ($part === 'migrations.php') {
/*
* Inject the SQL migrations into the delimited region, the same
* way the HTML is embedded above.
*/
$count = 0;
$content = preg_replace_callback(
'#// ___BEGIN_MIGRATIONS___\n.*?// ___END_MIGRATIONS___#s',
function () use ($migrationsPhp): string {
return "// ___BEGIN_MIGRATIONS___\n" .
$migrationsPhp . "\n// ___END_MIGRATIONS___";
},
$content,
1,
$count
);
if ($count !== 1) {
throw new RuntimeException(
'Could not find the embedded migrations region (matched ' . $count . ')'
);
}
}
if ($part === 'router.php' && $isRelease) {
/*
* Release builds must not contain any trace of the dev-only
* module, so strip the dispatch region from router.php.
*/
$count = 0;
$content = preg_replace(
'#\n?// ___BEGIN_DEV_ROUTE___\n.*?// ___END_DEV_ROUTE___\n?#s',
'',
$content,
1,
$count
);
if ($count !== 1) {
throw new RuntimeException(
'Could not strip the dev route region (matched ' . $count . ')'
);
}
}
$source .= $content . PHP_EOL;
}
if (!is_dir($outputDir)) {
mkdir($outputDir, 0777, true);
}
$tmp = $outputDir . '/.index.php.tmp';
if (file_put_contents($tmp, $source) === false) {
throw new RuntimeException('Could not write ' . $tmp);
}
if (!rename($tmp, $output)) {
unlink($tmp);
throw new RuntimeException('Could not write ' . $output);
}
echo PHP_EOL;
echo "Build complete." . PHP_EOL;
echo "Artifact: " . ($isRelease ? 'dist/sifpress.php (release, no dev.php)' : 'dist/index.php (dev, dev.php included)') . PHP_EOL;
echo PHP_EOL;
echo "No .htaccess, no rewrite rules are required." . PHP_EOL;
echo "Routes: /index.php?p=sifpress/admin/... API: /index.php?p=sifpress/api&action=..." . PHP_EOL;
echo "Want clean paths (/sifpress/admin/...)? php "
. ($isRelease ? 'dist/sifpress.php' : 'dist/index.php') . " rewrite both" . PHP_EOL;