@kirigami/php-prepros 1.0.0
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/LICENSE +21 -0
- package/README.md +622 -0
- package/index.d.ts +109 -0
- package/index.js +1 -0
- package/package.json +58 -0
- package/src/libraries/cache.class.php +101 -0
- package/src/libraries/fs.class.php +133 -0
- package/src/libraries/html.class.php +176 -0
- package/src/libraries/img.class.php +122 -0
- package/src/libraries/md.class.php +444 -0
- package/src/libraries/md.plugins.php +86 -0
- package/src/libraries/obf.class.php +23 -0
- package/src/libraries/prepros.class.php +157 -0
- package/src/libraries/prepros.plugins.php +28 -0
- package/src/libraries/std.class.php +28 -0
- package/src/libraries/str.class.php +47 -0
- package/src/libraries/yaml.class.php +453 -0
- package/src/prepros.js +107 -0
- package/src/prepros.php +45 -0
- package/src/utils/isbinary.js +79 -0
- package/src/utils/joinwith.js +12 -0
- package/src/utils.inc.php +29 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kirigami/php-prepros
|
|
3
|
+
*
|
|
4
|
+
* PHP preprocessor for the Kirigami static site generator.
|
|
5
|
+
* Runs PHP files through a WebAssembly PHP runtime to produce
|
|
6
|
+
* ready-to-deploy HTML pages.
|
|
7
|
+
*
|
|
8
|
+
* Requires a `kirigami.yaml` configuration file at the project root.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```js
|
|
12
|
+
* import { render, sitemap } from '@kirigami/php-prepros';
|
|
13
|
+
*
|
|
14
|
+
* // Compile a single page
|
|
15
|
+
* const result = await render('src/index.php');
|
|
16
|
+
*
|
|
17
|
+
* // Compile every page in the source directory
|
|
18
|
+
* const result = await render('src/');
|
|
19
|
+
*
|
|
20
|
+
* // Generate sitemap.xml
|
|
21
|
+
* const sitemap = await sitemap();
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Return types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returned by every prepros operation.
|
|
32
|
+
*/
|
|
33
|
+
export interface PreprosResult {
|
|
34
|
+
/** `true` when the operation completed without errors. */
|
|
35
|
+
success: boolean;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Paths of every file written to disk by this operation (relative to the
|
|
39
|
+
* project root). Includes the compiled HTML page(s) and any side-effect
|
|
40
|
+
* files such as `.cache.db` or resized images produced by `IMG::save()`.
|
|
41
|
+
*/
|
|
42
|
+
files: string[];
|
|
43
|
+
|
|
44
|
+
/** Human-readable error message. Only present when `success` is `false`. */
|
|
45
|
+
error?: string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Raw PHP stdout / stderr, useful for debugging.
|
|
49
|
+
* Only present when response parsing fails.
|
|
50
|
+
*/
|
|
51
|
+
response?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Public API
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Compile one PHP source file **or** an entire directory tree.
|
|
61
|
+
*
|
|
62
|
+
* - **Single file** — `_index.php` → `index.html` (leading underscore is
|
|
63
|
+
* stripped from the output filename).
|
|
64
|
+
* - **Directory** — every `_*.php` file found recursively is compiled.
|
|
65
|
+
* Files inside directories whose name starts with `_` are skipped.
|
|
66
|
+
*
|
|
67
|
+
* The PHPDOC block at the top of each source file drives the page:
|
|
68
|
+
*
|
|
69
|
+
* ```php
|
|
70
|
+
* <?php
|
|
71
|
+
* /**
|
|
72
|
+
* * @name index
|
|
73
|
+
* * @title My Page Title
|
|
74
|
+
* * @abstract Short description used as <meta name="description">
|
|
75
|
+
* * @articles _articles.yaml ← auto-parsed and injected as $articles
|
|
76
|
+
* * @data _data.json ← auto-parsed and injected as $data
|
|
77
|
+
* *\/
|
|
78
|
+
* ?>
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* Any `@tag filename` annotation whose extension is `.yaml`, `.yml`,
|
|
82
|
+
* `.json`, or `.md` is automatically loaded and made available as a PHP
|
|
83
|
+
* variable with the same name as the tag. Remote URLs are supported when
|
|
84
|
+
* `network: true` is set in `kirigami.yaml`.
|
|
85
|
+
*
|
|
86
|
+
* @param file Path to a `.php` source file or a directory, relative to the
|
|
87
|
+
* project root. Defaults to `.` (the entire source tree).
|
|
88
|
+
*
|
|
89
|
+
* @returns A {@link PreprosResult} describing what was written.
|
|
90
|
+
*
|
|
91
|
+
* @throws When the `kirigami.yaml` config is missing or malformed.
|
|
92
|
+
* @throws When `kirigami.root` does not exist on disk.
|
|
93
|
+
*/
|
|
94
|
+
export function render(file?: string): Promise<PreprosResult>;
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generate a `sitemap.xml` at the source root.
|
|
99
|
+
*
|
|
100
|
+
* Scans every `_index.php` found in the source tree and produces a
|
|
101
|
+
* standard Sitemaps 0.9 XML document. Priority is calculated from depth
|
|
102
|
+
* (root = 1.0, each extra level −0.1). `changefreq` is set to `weekly`.
|
|
103
|
+
*
|
|
104
|
+
* The base URL is taken from `kirigami.baseurl` in `kirigami.yaml`.
|
|
105
|
+
*
|
|
106
|
+
* @returns A {@link PreprosResult} with `files` containing the path to the
|
|
107
|
+
* generated `sitemap.xml`.
|
|
108
|
+
*/
|
|
109
|
+
export function sitemap(dir?: string): Promise<PreprosResult>;
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { render, sitemap } from "./src/prepros.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kirigami/php-prepros",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "PHP preprocessor for the Kirigami static site generator. Compile PHP page templates to clean, deployable HTML — with zero server dependency.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"kirigami",
|
|
7
|
+
"php",
|
|
8
|
+
"preprocessor",
|
|
9
|
+
"static-site-generator",
|
|
10
|
+
"ssg",
|
|
11
|
+
"wasm",
|
|
12
|
+
"php-wasm",
|
|
13
|
+
"markdown",
|
|
14
|
+
"yaml",
|
|
15
|
+
"github-pages",
|
|
16
|
+
"pages"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Maxime Larrivée-Roy",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"types": "index.d.ts",
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"index.d.ts",
|
|
26
|
+
"src/",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.10.0",
|
|
32
|
+
"npm": ">=10.2.3"
|
|
33
|
+
},
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"import": "./index.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@kirigami/php-wasm": "8.5.7-1",
|
|
48
|
+
"js-yaml": "^4.2.0"
|
|
49
|
+
},
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "https://github.com/php-kirigami/kirigami"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/php-kirigami/kirigami/#readme",
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/php-kirigami/kirigami/issues"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
// SYS::requireExtension('sqlite3');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CACHE
|
|
7
|
+
{
|
|
8
|
+
const DB_TIMEOUT = 10000;
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
private static function db(): SQLite3
|
|
12
|
+
{
|
|
13
|
+
static $db = null;
|
|
14
|
+
if($db !== null) return $db;
|
|
15
|
+
|
|
16
|
+
if(!empty($_SERVER['NODE_PROJECT'])) $root = $_SERVER['NODE_PROJECT'] . '/';
|
|
17
|
+
elseif(!empty(PREPROS::$config->root)) $root = rtrim(PREPROS::$config->root, '/') . '/';
|
|
18
|
+
else throw new Exception("Can't find project root.");
|
|
19
|
+
|
|
20
|
+
if(!is_file(($dbfile = self::getPath()))) $create = true;
|
|
21
|
+
if(!$db = new SQLite3($dbfile)) throw new Exception("Can't open .cache.db.");
|
|
22
|
+
|
|
23
|
+
$db->busyTimeout(static::DB_TIMEOUT);
|
|
24
|
+
if(!empty($create) && !$db->exec('
|
|
25
|
+
CREATE TABLE "data" (
|
|
26
|
+
"key" TEXT(128) NOT NULL,
|
|
27
|
+
"val" TEXT DEFAULT NULL,
|
|
28
|
+
"inserted_at" INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
"ttl" INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
PRIMARY KEY ("key")
|
|
31
|
+
);
|
|
32
|
+
CREATE UNIQUE INDEX "key" ON "data" ("key");
|
|
33
|
+
')) throw new Exception("Can't create .cache.db.");
|
|
34
|
+
return $db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
private static function getPath() {
|
|
39
|
+
return '/project/.cache.db';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
public static function get(string $key): mixed
|
|
44
|
+
{
|
|
45
|
+
static $query = null;
|
|
46
|
+
if(!$query) $query = self::db()->prepare(
|
|
47
|
+
"SELECT val FROM data
|
|
48
|
+
WHERE key = ?
|
|
49
|
+
AND (ttl = 0 OR inserted_at + ttl >= :now)"
|
|
50
|
+
);
|
|
51
|
+
$query->bindValue(1, $key, SQLITE3_TEXT);
|
|
52
|
+
$query->bindValue(':now', time(), SQLITE3_INTEGER);
|
|
53
|
+
$result = $query->execute();
|
|
54
|
+
$query->reset();
|
|
55
|
+
if(!$result) return null;
|
|
56
|
+
$row = $result->fetchArray(SQLITE3_ASSOC);
|
|
57
|
+
$result->finalize();
|
|
58
|
+
if($row === false) return null;
|
|
59
|
+
return unserialize($row['val']);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
public static function set(string $key, mixed $val, int $ttl = 0): bool
|
|
64
|
+
{
|
|
65
|
+
static $query = null;
|
|
66
|
+
if(!$query) $query = self::db()->prepare(
|
|
67
|
+
"INSERT OR REPLACE INTO data(key, val, inserted_at, ttl)
|
|
68
|
+
VALUES(?, ?, ?, ?)"
|
|
69
|
+
);
|
|
70
|
+
$query->bindValue(1, $key, SQLITE3_TEXT);
|
|
71
|
+
$query->bindValue(2, serialize($val), SQLITE3_TEXT);
|
|
72
|
+
$query->bindValue(3, time(), SQLITE3_INTEGER);
|
|
73
|
+
$query->bindValue(4, $ttl, SQLITE3_INTEGER);
|
|
74
|
+
$success = (bool)@$query->execute();
|
|
75
|
+
$query->reset();
|
|
76
|
+
PREPROS::exportFile(self::getPath());
|
|
77
|
+
return $success;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
public static function purge(): bool
|
|
82
|
+
{
|
|
83
|
+
PREPROS::exportFile(self::getPath());
|
|
84
|
+
return (bool)self::db()->exec(
|
|
85
|
+
"DELETE FROM data WHERE ttl > 0 AND inserted_at + ttl < " . time()
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
public static function delete(string $key): bool
|
|
91
|
+
{
|
|
92
|
+
static $query = null;
|
|
93
|
+
if(!$query) $query = self::db()->prepare("DELETE FROM data WHERE key = ?");
|
|
94
|
+
$query->bindValue(1, $key, SQLITE3_TEXT);
|
|
95
|
+
$success = (bool)@$query->execute();
|
|
96
|
+
$query->reset();
|
|
97
|
+
PREPROS::exportFile(self::getPath());
|
|
98
|
+
return $success;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class FS
|
|
5
|
+
{
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
public static function dig(string $path): iterable
|
|
9
|
+
{
|
|
10
|
+
$patt = pathinfo($path, PATHINFO_BASENAME);
|
|
11
|
+
$path = pathinfo($path, PATHINFO_DIRNAME);
|
|
12
|
+
if ($path = realpath($path)) {
|
|
13
|
+
$path .= '/';
|
|
14
|
+
foreach (glob($path . $patt) as $file) {
|
|
15
|
+
if (!is_dir($file)) yield $file;
|
|
16
|
+
}
|
|
17
|
+
foreach (glob($path . '*', GLOB_ONLYDIR) as $dir) {
|
|
18
|
+
foreach (call_user_func(__METHOD__, $dir . '/' . $patt) as $file) yield $file;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
public static function getRelativePath(string $from, string $to): string
|
|
25
|
+
{
|
|
26
|
+
$from = is_dir($from) ? rtrim($from, '\/') . '/' : $from;
|
|
27
|
+
$to = is_dir($to) ? rtrim($to, '\/') . '/' : $to;
|
|
28
|
+
$from = str_replace('\\', '/', $from);
|
|
29
|
+
$to = str_replace('\\', '/', $to);
|
|
30
|
+
$from = explode('/', $from);
|
|
31
|
+
$to = explode('/', $to);
|
|
32
|
+
$relPath = $to;
|
|
33
|
+
|
|
34
|
+
foreach ($from as $depth => $dir) {
|
|
35
|
+
if ($dir === $to[$depth]) {
|
|
36
|
+
array_shift($relPath);
|
|
37
|
+
} else {
|
|
38
|
+
$remaining = count($from) - $depth;
|
|
39
|
+
if ($remaining > 1) {
|
|
40
|
+
$padLength = (count($relPath) + $remaining - 1) * -1;
|
|
41
|
+
$relPath = array_pad($relPath, $padLength, '..');
|
|
42
|
+
break;
|
|
43
|
+
} else {
|
|
44
|
+
$relPath[0] = './' . $relPath[0];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return $relPath ? implode('/', $relPath) : './';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
public static function phpFileInfo(string $file): object|bool
|
|
53
|
+
{
|
|
54
|
+
static $files = [];
|
|
55
|
+
if (!$file = realpath($file)) return false;
|
|
56
|
+
if (!isset($files[$file])) {
|
|
57
|
+
$tokens = token_get_all(file_get_contents($file));
|
|
58
|
+
foreach ($tokens as $tok) {
|
|
59
|
+
if (!is_array($tok)) continue;
|
|
60
|
+
if ($tok[0] == T_DOC_COMMENT) {
|
|
61
|
+
$block = $tok[1];
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (empty($block)) return new stdClass;
|
|
66
|
+
if (!preg_match_all('#@([a-z0-9]+)[\s\t]+([^\n]+)#msi', $block, $m)) $files[$file] = new stdClass;
|
|
67
|
+
else {
|
|
68
|
+
$info = [];
|
|
69
|
+
foreach ($m[1] as $k => $v) $info[trim($v)] = trim($m[2][$k]);
|
|
70
|
+
$files[$file] = (object)$info;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return $files[$file];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
public static function rmdir(string $dir, bool $removeSelf = true): bool
|
|
78
|
+
{
|
|
79
|
+
if (!file_exists($dir)) return true;
|
|
80
|
+
if (is_file($dir) || is_link($dir)) return @unlink($dir);
|
|
81
|
+
$items = @scandir($dir);
|
|
82
|
+
if ($items === false) return false;
|
|
83
|
+
foreach ($items as $item) {
|
|
84
|
+
if ($item === '.' || $item === '..') continue;
|
|
85
|
+
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
|
86
|
+
if (is_dir($path) && !is_link($path)) {
|
|
87
|
+
if (!call_user_func(__METHOD__, $path, true)) return false;
|
|
88
|
+
} else {
|
|
89
|
+
if (!@unlink($path)) return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return $removeSelf ? @rmdir($dir) : true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
public static function pathJoin(string ...$parts): string
|
|
97
|
+
{
|
|
98
|
+
if (empty($parts)) return '';
|
|
99
|
+
$isAbsolute = str_starts_with($parts[0], '/');
|
|
100
|
+
$hasTrailingSlash = str_ends_with(end($parts), '/');
|
|
101
|
+
|
|
102
|
+
$prefix = '';
|
|
103
|
+
$isUrl = (bool) preg_match('#^[a-zA-Z][a-zA-Z0-9+\-.]*://#', $parts[0]);
|
|
104
|
+
if ($isUrl) {
|
|
105
|
+
preg_match('#^([a-zA-Z][a-zA-Z0-9+\-.]*://[^/]*)(.*)$#', $parts[0], $m);
|
|
106
|
+
$prefix = $m[1];
|
|
107
|
+
$parts[0] = $m[2] ?? '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
$merged = implode('/', $parts);
|
|
111
|
+
$merged = preg_replace('#/+#', '/', $merged);
|
|
112
|
+
|
|
113
|
+
$segments = explode('/', $merged);
|
|
114
|
+
$resolved = [];
|
|
115
|
+
foreach ($segments as $seg) {
|
|
116
|
+
if ($seg === '' || $seg === '.') {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if ($seg === '..') {
|
|
120
|
+
if (!empty($resolved)) {
|
|
121
|
+
array_pop($resolved);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
$resolved[] = $seg;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
$path = implode('/', $resolved);
|
|
129
|
+
if ($isAbsolute || $isUrl) $path = '/' . $path;
|
|
130
|
+
if ($hasTrailingSlash && !str_ends_with($path, '/')) $path .= '/';
|
|
131
|
+
return $prefix . $path;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HtmlFormatter — formateur HTML pour Kirigami
|
|
5
|
+
* Utilise Dom\HTMLDocument (PHP 8.4, moteur Lexbor) pour parser,
|
|
6
|
+
* puis re-sérialise avec indentation.
|
|
7
|
+
* Les blocs <script> et <style> sont indentés au bon niveau mais non reformatés.
|
|
8
|
+
*/
|
|
9
|
+
class HTML
|
|
10
|
+
{
|
|
11
|
+
private const INDENT = 4;
|
|
12
|
+
|
|
13
|
+
// Attributs booléens HTML5 — écrits sans valeur
|
|
14
|
+
private const BOOLEAN_ATTRS = [
|
|
15
|
+
'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked',
|
|
16
|
+
'controls', 'default', 'defer', 'disabled', 'formnovalidate',
|
|
17
|
+
'hidden', 'ismap', 'loop', 'multiple', 'muted', 'nomodule',
|
|
18
|
+
'noopener', 'noreferrer', 'novalidate', 'open', 'playsinline',
|
|
19
|
+
'readonly', 'required', 'reversed', 'selected', 'webkit-playsinline',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// Éléments inline — leur présence dans un parent n'empêche pas le rendu inline
|
|
23
|
+
private const INLINE = [
|
|
24
|
+
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'button', 'cite',
|
|
25
|
+
'code', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'map',
|
|
26
|
+
'mark', 'object', 'output', 'q', 's', 'samp', 'select', 'small',
|
|
27
|
+
'span', 'strong', 'sub', 'sup', 'textarea', 'time', 'tt', 'u', 'var',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
private const RAW = ['script', 'style'];
|
|
31
|
+
private const VOID = [
|
|
32
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
33
|
+
'link', 'meta', 'source', 'track', 'wbr',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
public static function format(string $html): string
|
|
37
|
+
{
|
|
38
|
+
$dom = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);
|
|
39
|
+
|
|
40
|
+
$output = '';
|
|
41
|
+
foreach ($dom->childNodes as $node) {
|
|
42
|
+
$output .= static::renderNode($node, 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
$output = preg_replace('/\n{3,}/', "\n\n", $output);
|
|
46
|
+
|
|
47
|
+
return rtrim($output) . "\n";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private static function renderNode(Dom\Node $node, int $depth): string
|
|
51
|
+
{
|
|
52
|
+
return match ($node->nodeType) {
|
|
53
|
+
XML_TEXT_NODE => static::renderText($node, $depth),
|
|
54
|
+
XML_COMMENT_NODE => static::renderComment($node, $depth),
|
|
55
|
+
XML_DOCUMENT_TYPE_NODE => static::renderDoctype(),
|
|
56
|
+
default => static::renderElement($node, $depth),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private static function renderElement(Dom\Node $node, int $depth): string
|
|
61
|
+
{
|
|
62
|
+
$tag = strtolower($node->nodeName);
|
|
63
|
+
$pad = str_repeat(' ', $depth * self::INDENT);
|
|
64
|
+
$attrs = static::renderAttrs($node);
|
|
65
|
+
$isVoid = in_array($tag, self::VOID, true);
|
|
66
|
+
$isRaw = in_array($tag, self::RAW, true);
|
|
67
|
+
|
|
68
|
+
if ($isVoid) {
|
|
69
|
+
return "{$pad}<{$tag}{$attrs}>\n";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if ($isRaw) {
|
|
73
|
+
$inner = trim($node->innerHTML, "\n\r");
|
|
74
|
+
if (trim($inner) === '') {
|
|
75
|
+
return "{$pad}<{$tag}{$attrs}></{$tag}>\n";
|
|
76
|
+
}
|
|
77
|
+
$childPad = str_repeat(' ', ($depth + 1) * self::INDENT);
|
|
78
|
+
$lines = explode("\n", $inner);
|
|
79
|
+
|
|
80
|
+
// Calcule le niveau d'indentation minimal existant (ignore les lignes vides)
|
|
81
|
+
$minIndent = PHP_INT_MAX;
|
|
82
|
+
foreach ($lines as $line) {
|
|
83
|
+
if (trim($line) === '') continue;
|
|
84
|
+
$minIndent = min($minIndent, strlen($line) - strlen(ltrim($line)));
|
|
85
|
+
}
|
|
86
|
+
$minIndent = $minIndent === PHP_INT_MAX ? 0 : $minIndent;
|
|
87
|
+
|
|
88
|
+
// Dédente puis ré-indente au bon niveau
|
|
89
|
+
$indented = implode("\n", array_map(
|
|
90
|
+
static fn(string $line) => trim($line) !== ''
|
|
91
|
+
? $childPad . substr($line, $minIndent)
|
|
92
|
+
: '',
|
|
93
|
+
$lines
|
|
94
|
+
));
|
|
95
|
+
return "{$pad}<{$tag}{$attrs}>\n" . rtrim($indented) . "\n{$pad}</{$tag}>\n";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
$children = iterator_to_array($node->childNodes);
|
|
99
|
+
|
|
100
|
+
if (empty($children)) {
|
|
101
|
+
return "{$pad}<{$tag}{$attrs}></{$tag}>\n";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Si tous les enfants sont inline, on utilise innerHTML tel quel — pas de reconstruction
|
|
105
|
+
if (static::hasOnlyInlineChildren($node)) {
|
|
106
|
+
$inner = trim($node->innerHTML);
|
|
107
|
+
return "{$pad}<{$tag}{$attrs}>{$inner}</{$tag}>\n";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
$inner = '';
|
|
111
|
+
foreach ($children as $child) {
|
|
112
|
+
$inner .= static::renderNode($child, $depth + 1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return "{$pad}<{$tag}{$attrs}>\n{$inner}{$pad}</{$tag}>\n";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Vérifie que tous les descendants directs sont inline (texte, void inline, éléments inline)
|
|
119
|
+
// Les éléments inline eux-mêmes ne doivent pas contenir d'éléments block
|
|
120
|
+
private static function hasOnlyInlineChildren(Dom\Node $node): bool
|
|
121
|
+
{
|
|
122
|
+
foreach ($node->childNodes as $child) {
|
|
123
|
+
if ($child->nodeType === XML_TEXT_NODE) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if ($child->nodeType !== XML_ELEMENT_NODE) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
if (!in_array(strtolower($child->nodeName), self::INLINE, true)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
// Récursif : l'élément inline ne doit pas contenir d'éléments block
|
|
133
|
+
if (!static::hasOnlyInlineChildren($child)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private static function renderText(Dom\Node $node, int $depth): string
|
|
141
|
+
{
|
|
142
|
+
$text = trim($node->nodeValue);
|
|
143
|
+
if ($text === '') {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
$pad = str_repeat(' ', $depth * self::INDENT);
|
|
147
|
+
return $pad . htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8') . "\n";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private static function renderComment(Dom\Node $node, int $depth): string
|
|
151
|
+
{
|
|
152
|
+
$pad = str_repeat(' ', $depth * self::INDENT);
|
|
153
|
+
return "{$pad}<!--{$node->nodeValue}-->\n";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private static function renderDoctype(): string
|
|
157
|
+
{
|
|
158
|
+
return "<!DOCTYPE html>\n";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private static function renderAttrs(Dom\Node $node): string
|
|
162
|
+
{
|
|
163
|
+
$out = '';
|
|
164
|
+
if (!$node->hasAttributes()) {
|
|
165
|
+
return $out;
|
|
166
|
+
}
|
|
167
|
+
foreach ($node->attributes as $attr) {
|
|
168
|
+
if (in_array($attr->name, self::BOOLEAN_ATTRS, true)) {
|
|
169
|
+
$out .= ' ' . $attr->name;
|
|
170
|
+
} else {
|
|
171
|
+
$out .= ' ' . $attr->name . '="' . htmlspecialchars($attr->value, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '"';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return $out;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IMG
|
|
5
|
+
{
|
|
6
|
+
|
|
7
|
+
private GdImage|null $im = null;
|
|
8
|
+
private array|null $info = null;
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
public function __construct(string $file)
|
|
12
|
+
{
|
|
13
|
+
if (!is_file($file) || !is_readable($file)) throw new Exception("Source file unreadable.");
|
|
14
|
+
|
|
15
|
+
$this->info = @getimagesize($file);
|
|
16
|
+
if (!$this->info) throw new Exception("Invalid image file.");
|
|
17
|
+
|
|
18
|
+
[$srcW, $srcH, $type] = $this->info;
|
|
19
|
+
if ($srcW <= 0 || $srcH <= 0) throw new Exception("Invalid image file.");
|
|
20
|
+
|
|
21
|
+
$this->im = match ($type) {
|
|
22
|
+
IMAGETYPE_JPEG => @imagecreatefromjpeg($file),
|
|
23
|
+
IMAGETYPE_PNG => @imagecreatefrompng($file),
|
|
24
|
+
IMAGETYPE_GIF => @imagecreatefromgif($file),
|
|
25
|
+
IMAGETYPE_WEBP => (function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($file) : null),
|
|
26
|
+
default => throw new Exception("Image format not supported."),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
public function __get(string $name)
|
|
32
|
+
{
|
|
33
|
+
switch (strtolower($name)) {
|
|
34
|
+
case "w":
|
|
35
|
+
case "width":
|
|
36
|
+
return imagesx($this->im);
|
|
37
|
+
case "h":
|
|
38
|
+
case "height":
|
|
39
|
+
return imagesy($this->im);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
private function setNew(GdImage $im) {
|
|
45
|
+
$this->im = $im;
|
|
46
|
+
return $this;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
public function resize(int $width, int $height = 0, bool $cover = false)
|
|
51
|
+
{
|
|
52
|
+
$srcRatio = $this->width / $this->height;
|
|
53
|
+
|
|
54
|
+
if (!$height) {
|
|
55
|
+
$height = $this->height * $width / $this->width;
|
|
56
|
+
$dstRatio = $srcRatio;
|
|
57
|
+
} else {
|
|
58
|
+
$dstRatio = $width / $height;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if ($cover) { // ===> Cover
|
|
62
|
+
if ($srcRatio > $dstRatio) {
|
|
63
|
+
$cropH = $this->height;
|
|
64
|
+
$cropW = (int) round($this->height * $dstRatio);
|
|
65
|
+
} else {
|
|
66
|
+
$cropW = $this->width;
|
|
67
|
+
$cropH = (int) round($this->width / $dstRatio);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
$srcX = (int) max(0, floor(($this->width - $cropW) / 2));
|
|
71
|
+
$srcY = (int) max(0, floor(($this->height - $cropH) / 2));
|
|
72
|
+
$outW = $width;
|
|
73
|
+
$outH = $height;
|
|
74
|
+
$dst = $this->prepare($outW, $outH);
|
|
75
|
+
$ok = imagecopyresampled($dst, $this->im, 0, 0, $srcX, $srcY, $outW, $outH, $cropW, $cropH);
|
|
76
|
+
} else { // ===> Contain
|
|
77
|
+
$scale = min($width / $this->width, $height / $this->height);
|
|
78
|
+
$outW = (int) max(1, round($this->width * $scale));
|
|
79
|
+
$outH = (int) max(1, round($this->height * $scale));
|
|
80
|
+
$dst = $this->prepare($outW, $outH);
|
|
81
|
+
$ok = imagecopyresampled($dst, $this->im, 0, 0, 0, 0, $outW, $outH, $this->width, $this->height);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!$ok) throw new Exception("Can't resample source image.");
|
|
85
|
+
|
|
86
|
+
return $this->setNew($dst);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
private function prepare(int $x, int $y): GdImage
|
|
91
|
+
{
|
|
92
|
+
$img = imagecreatetruecolor($x, $y);
|
|
93
|
+
$hasAlpha = in_array($this->info[2], [IMAGETYPE_PNG, IMAGETYPE_WEBP, IMAGETYPE_GIF], true);
|
|
94
|
+
if ($hasAlpha) {
|
|
95
|
+
imagealphablending($img, false);
|
|
96
|
+
imagesavealpha($img, true);
|
|
97
|
+
$transparent = imagecolorallocatealpha($img, 0, 0, 0, 127);
|
|
98
|
+
imagefilledrectangle($img, 0, 0, imagesx($img), imagesy($img), $transparent);
|
|
99
|
+
} else {
|
|
100
|
+
$white = imagecolorallocate($img, 255, 255, 255);
|
|
101
|
+
imagefilledrectangle($img, 0, 0, imagesx($img), imagesy($img), $white);
|
|
102
|
+
}
|
|
103
|
+
return $img;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
public function save(string $dest): self
|
|
108
|
+
{
|
|
109
|
+
$ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
|
|
110
|
+
$dir = pathinfo($dest, PATHINFO_DIRNAME);
|
|
111
|
+
if (!is_dir($dir) && !@mkdir($dir, 0777, true)) throw new Exception("Invalid destination.");
|
|
112
|
+
match ($ext) {
|
|
113
|
+
'jpg', 'jpeg' => imagejpeg($this->im, $dest, 85),
|
|
114
|
+
'png' => imagepng($this->im, $dest, 6),
|
|
115
|
+
'gif' => imagegif($this->im, $dest),
|
|
116
|
+
'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, 85) : false),
|
|
117
|
+
default => throw new Exception("Invalid output file type.")
|
|
118
|
+
};
|
|
119
|
+
PREPROS::exportFile(realpath($dest));
|
|
120
|
+
return $this;
|
|
121
|
+
}
|
|
122
|
+
}
|