placeholder-photo-migrate 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/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ ## 1.0.0 - 2026-09-16
6
+
7
+ - Add local, dependency-free migration CLI with dry-run as the default.
8
+ - Add verified providers for `via.placeholder.com`, `placehold.it`, and conservative legacy `placeholder.com` URLs.
9
+ - Add `--write`, `--check`, `--provider`, and local JSON `--report` support.
10
+ - Add manual-review classification for ambiguous or unsupported URLs.
11
+ - Add source-format filtering, default directory exclusions, `.gitignore` support, binary detection, and symlink containment.
12
+ - Add automated tests and GitHub Actions coverage for supported Node.js releases.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jansma VIP / Placeholder.photo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # Placeholder.photo Migration CLI
2
+
3
+ The official, local migration tool for moving verified legacy placeholder image URLs to [Placeholder.photo](https://placeholder.photo).
4
+
5
+ Placeholder.photo is a free URL-first image toolkit operated by Jansma VIP. This CLI finds common dead or legacy placeholder URLs, classifies them against Placeholder.photo's versioned compatibility rules, and can safely update the compatible cases.
6
+
7
+ ## Quick start
8
+
9
+ Preview a migration without changing files:
10
+
11
+ ```sh
12
+ npx placeholder-photo-migrate .
13
+ ```
14
+
15
+ Apply only verified, safe migrations:
16
+
17
+ ```sh
18
+ npx placeholder-photo-migrate . --write
19
+ ```
20
+
21
+ Fail CI when legacy URLs remain:
22
+
23
+ ```sh
24
+ npx placeholder-photo-migrate . --check
25
+ ```
26
+
27
+ Dry-run is always the default. Source files are changed only when `--write` is explicitly present.
28
+
29
+ ## What it migrates
30
+
31
+ The CLI has a separate, centrally tested provider for each legacy service:
32
+
33
+ | Provider | Example | Automatic behavior |
34
+ |---|---|---|
35
+ | `via.placeholder.com` | `https://via.placeholder.com/600/92c952` | Verified classic paths are migrated |
36
+ | `placehold.it` | `http://placehold.it/300x200` | Verified classic paths are migrated |
37
+ | legacy `placeholder.com` | `https://placeholder.com/300x200` | Only an exact, provable image path is migrated |
38
+
39
+ Example:
40
+
41
+ ```text
42
+ https://via.placeholder.com/300x200/000000/ffffff?text=Hello+World
43
+ https://placeholder.photo/300x200/000000/ffffff?text=Hello+World
44
+ ```
45
+
46
+ Safe migrations retain the dimensions, background and foreground colours, custom text, URL encoding, trailing slash, query string, and verified raster extension exactly. HTTP, HTTPS, and protocol-relative source URLs all become the canonical HTTPS Placeholder.photo URL.
47
+
48
+ The automatic compatibility subset is deliberately narrow:
49
+
50
+ - square or `width×height` dimensions within live rendering limits;
51
+ - optional 3- or 6-digit hexadecimal background and text colours;
52
+ - optional `.png`, `.jpg`, `.jpeg`, `.gif`, or `.webp` suffix;
53
+ - one optional, correctly encoded `text` query parameter;
54
+ - the historical `/img/` path alias accepted by Placeholder.photo.
55
+
56
+ Anything ambiguous is left unchanged and reported as **manual review required**. In particular, the CLI never performs a blind replacement of every `placeholder.com` reference.
57
+
58
+ See the official [compatibility guide](https://placeholder.photo/compatibility) and [migration CLI documentation](https://placeholder.photo/docs/migration-cli).
59
+
60
+ ## Options
61
+
62
+ ```text
63
+ --write Apply safe migrations (default is dry-run)
64
+ --check CI check; never writes source files
65
+ --provider <provider> Limit the scan to one or more providers
66
+ --report <file> Write a local JSON report inside the project
67
+ -h, --help Show help
68
+ -v, --version Show version
69
+ ```
70
+
71
+ Provider names are `via-placeholder`, `placehold-it`, and `placeholder-com`. `--provider` can be repeated or given a comma-separated list.
72
+
73
+ ```sh
74
+ npx placeholder-photo-migrate . --provider via-placeholder
75
+ npx placeholder-photo-migrate . --provider via-placeholder,placehold-it --report migration-report.json
76
+ ```
77
+
78
+ Reports contain file locations and URL-level decisions. They are written locally, are excluded from their own scan, and are never transmitted.
79
+
80
+ ## Supported source files
81
+
82
+ The scanner handles:
83
+
84
+ ```text
85
+ .html .htm .css .scss .sass .less
86
+ .js .jsx .mjs .cjs .ts .tsx
87
+ .vue .svelte .php .md .mdx
88
+ .json .jsonc .yaml .yml .xml .txt
89
+ ```
90
+
91
+ It skips binary and invalid UTF-8 files, files larger than 10 MiB, symlinks, unsupported extensions, and these directories by default:
92
+
93
+ ```text
94
+ .git node_modules vendor dist build coverage .cache tmp temp
95
+ ```
96
+
97
+ The root `.gitignore` is respected for common ignore patterns. Symlinks are not followed, report files must remain inside the selected project, and writes use same-directory atomic replacement.
98
+
99
+ ## CI
100
+
101
+ ```yaml
102
+ - name: Check legacy placeholder URLs
103
+ run: npx --yes placeholder-photo-migrate . --check
104
+ ```
105
+
106
+ Exit codes:
107
+
108
+ | Code | Meaning |
109
+ |---:|---|
110
+ | `0` | No selected legacy URLs were found |
111
+ | `1` | Legacy URLs were found, including manual-review findings |
112
+ | `2` | Usage, filesystem, or runtime error |
113
+
114
+ A successful `--write` run still returns `1` because legacy URLs were found during that run. Run the command again: a fully migrated project returns `0` unless unresolved manual-review URLs remain.
115
+
116
+ ## Privacy and security
117
+
118
+ **Your source code is never uploaded to Placeholder.photo.**
119
+
120
+ The CLI runs entirely on the developer's computer. It contains no telemetry, analytics, source upload, project identifiers, or migration-time network API. After npm installation, normal scanning and migration work offline using local, versioned compatibility rules.
121
+
122
+ Filenames and contents are treated as untrusted input. The tool does not execute project files or shell commands, follows no symlinks, bounds file size and URL patterns, and refuses writes outside the selected project.
123
+
124
+ ## Requirements and limitations
125
+
126
+ - Node.js 22 or newer.
127
+ - The tool migrates textual source files, not generated binaries or databases.
128
+ - Only syntax covered by Placeholder.photo's verified compatibility implementation is changed automatically.
129
+ - Font metrics, antialiasing, watermarking, and static GIF behavior may differ from discontinued services; see the [compatibility guide](https://placeholder.photo/compatibility).
130
+ - Unknown parameters, malformed URLs, hostname variants, explicit ports, unsupported formats, and out-of-limit dimensions require manual review.
131
+
132
+ ## Support
133
+
134
+ - [Placeholder.photo](https://placeholder.photo)
135
+ - [Compatibility documentation](https://placeholder.photo/compatibility)
136
+ - [GitHub Issues](https://github.com/Jansma-VIP/placeholder-photo-migrate/issues)
137
+
138
+ ## Ownership and license
139
+
140
+ Built and maintained by Jansma VIP / Placeholder.photo. Released under the [MIT License](LICENSE).
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/cli.js';
4
+
5
+ process.exitCode = await runCli(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "placeholder-photo-migrate",
3
+ "version": "1.0.0",
4
+ "description": "Safely migrate legacy placeholder image URLs to Placeholder.photo.",
5
+ "type": "module",
6
+ "bin": {
7
+ "placeholder-photo-migrate": "bin/placeholder-photo-migrate.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "test": "node --test",
21
+ "test:coverage": "node --test --experimental-test-coverage",
22
+ "lint": "node --check bin/placeholder-photo-migrate.js && node --check src/*.js && node --check src/providers/*.js && node --check test/*.test.js",
23
+ "prepack": "npm run lint && npm test"
24
+ },
25
+ "engines": {
26
+ "node": ">=22.0.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/Jansma-VIP/placeholder-photo-migrate.git"
34
+ },
35
+ "homepage": "https://placeholder.photo",
36
+ "bugs": {
37
+ "url": "https://github.com/Jansma-VIP/placeholder-photo-migrate/issues"
38
+ },
39
+ "author": {
40
+ "name": "Jansma VIP / Placeholder.photo",
41
+ "url": "https://placeholder.photo"
42
+ },
43
+ "license": "MIT",
44
+ "keywords": [
45
+ "placeholder",
46
+ "migration",
47
+ "codemod",
48
+ "via.placeholder.com",
49
+ "placehold.it",
50
+ "placeholder.photo"
51
+ ]
52
+ }
package/src/cli.js ADDED
@@ -0,0 +1,173 @@
1
+ import path from 'node:path';
2
+ import {
3
+ collectSourceFiles,
4
+ isGeneratedReport,
5
+ readTextFile,
6
+ resolveReportPath,
7
+ resolveScanTarget,
8
+ safeWriteTextFile,
9
+ writeReportFile,
10
+ } from './files.js';
11
+ import { parseOptions } from './options.js';
12
+ import { normalizeProviderIds } from './providers/index.js';
13
+ import { createReport } from './report.js';
14
+ import { applySafeFindings, scanSource } from './scanner.js';
15
+ import { VERSION } from './version.js';
16
+
17
+ const HELP = `placeholder-photo-migrate ${VERSION}
18
+
19
+ Safely migrate verified legacy placeholder image URLs to https://placeholder.photo.
20
+
21
+ Usage:
22
+ placeholder-photo-migrate [path] [options]
23
+
24
+ Examples:
25
+ npx placeholder-photo-migrate .
26
+ npx placeholder-photo-migrate . --write
27
+ npx placeholder-photo-migrate . --check
28
+
29
+ Options:
30
+ --write Apply safe migrations (default is dry-run)
31
+ --check CI check; never writes source files
32
+ --provider <provider> Limit to via-placeholder, placehold-it, or placeholder-com
33
+ --report <file> Write a local JSON report inside the selected project
34
+ -h, --help Show help
35
+ -v, --version Show version
36
+
37
+ Exit codes:
38
+ 0 No legacy URLs found
39
+ 1 Legacy URLs found (including manual-review findings)
40
+ 2 Runtime or usage error
41
+ `;
42
+
43
+ const MAX_FINDINGS = 100_000;
44
+ const MAX_PENDING_WRITE_BYTES = 128 * 1024 * 1024;
45
+
46
+ function relativeFile(root, file) {
47
+ return path.relative(root, file).replaceAll(path.sep, '/') || path.basename(file);
48
+ }
49
+
50
+ function writeLine(stream, value = '') {
51
+ stream.write(`${value}\n`);
52
+ }
53
+
54
+ function safeDisplay(value) {
55
+ return String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) => {
56
+ return `\\u${character.codePointAt(0).toString(16).padStart(4, '0')}`;
57
+ });
58
+ }
59
+
60
+ export async function runCli(argv, io = {}) {
61
+ const stdout = io.stdout ?? process.stdout;
62
+ const stderr = io.stderr ?? process.stderr;
63
+
64
+ try {
65
+ const options = parseOptions(argv);
66
+ if (options.help) {
67
+ stdout.write(HELP);
68
+ return 0;
69
+ }
70
+ if (options.version) {
71
+ writeLine(stdout, VERSION);
72
+ return 0;
73
+ }
74
+
75
+ const selectedProviderIds = normalizeProviderIds(options.providers);
76
+ const scanTarget = await resolveScanTarget(options.target);
77
+ const reportPath = options.report ? await resolveReportPath(options.report, scanTarget.root) : null;
78
+ const excludedPaths = new Set(reportPath ? [reportPath] : []);
79
+ const collected = await collectSourceFiles(scanTarget, excludedPaths);
80
+ const findings = [];
81
+ const pendingWrites = [];
82
+ let pendingWriteBytes = 0;
83
+ let filesScanned = 0;
84
+
85
+ for (const file of collected.files) {
86
+ const loaded = await readTextFile(file);
87
+ if (loaded.kind === 'binary') {
88
+ collected.skipped.binary += 1;
89
+ continue;
90
+ }
91
+ if (loaded.kind === 'large') {
92
+ collected.skipped.large += 1;
93
+ continue;
94
+ }
95
+ if (isGeneratedReport(loaded.content)) {
96
+ collected.skipped.report += 1;
97
+ continue;
98
+ }
99
+
100
+ filesScanned += 1;
101
+ const fileFindings = scanSource(loaded.content, selectedProviderIds).map((finding) => ({
102
+ ...finding,
103
+ file: relativeFile(scanTarget.root, file),
104
+ }));
105
+ findings.push(...fileFindings);
106
+ if (findings.length > MAX_FINDINGS) {
107
+ throw new Error(`Scan exceeded the safety limit of ${MAX_FINDINGS} findings.`);
108
+ }
109
+
110
+ if (options.write && fileFindings.some((finding) => finding.status === 'safe')) {
111
+ const migrated = `${loaded.hasBom ? '\uFEFF' : ''}${applySafeFindings(loaded.content, fileFindings)}`;
112
+ pendingWriteBytes += Buffer.byteLength(migrated, 'utf8');
113
+ if (pendingWriteBytes > MAX_PENDING_WRITE_BYTES) {
114
+ throw new Error('Pending migrations exceed the 128 MiB write safety limit. Migrate a smaller project scope.');
115
+ }
116
+ pendingWrites.push({
117
+ file,
118
+ content: migrated,
119
+ mode: loaded.mode,
120
+ });
121
+ }
122
+ }
123
+
124
+ for (const finding of findings) {
125
+ const location = `${safeDisplay(finding.file)}:${finding.line}:${finding.column}`;
126
+ if (finding.status === 'safe') {
127
+ writeLine(stdout, `SAFE ${location} ${safeDisplay(finding.original)}`);
128
+ writeLine(stdout, ` -> ${safeDisplay(finding.replacement)}`);
129
+ } else {
130
+ writeLine(stdout, `MANUAL ${location} ${safeDisplay(finding.original)}`);
131
+ writeLine(stdout, ` ${safeDisplay(finding.reason)}`);
132
+ }
133
+ }
134
+
135
+ for (const pending of pendingWrites) {
136
+ await safeWriteTextFile(pending.file, pending.content, scanTarget.root, pending.mode);
137
+ }
138
+
139
+ const safeCount = findings.filter((finding) => finding.status === 'safe').length;
140
+ const manualCount = findings.length - safeCount;
141
+ const mode = options.write ? 'write' : options.check ? 'check' : 'dry-run';
142
+
143
+ if (reportPath) {
144
+ const report = createReport({
145
+ mode,
146
+ root: scanTarget.root,
147
+ target: scanTarget.target,
148
+ filesScanned,
149
+ filesChanged: pendingWrites.length,
150
+ findings,
151
+ skipped: collected.skipped,
152
+ });
153
+ await writeReportFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, scanTarget.root);
154
+ writeLine(stdout, `Report: ${relativeFile(scanTarget.root, reportPath)}`);
155
+ }
156
+
157
+ if (findings.length === 0) {
158
+ writeLine(stdout, `No legacy placeholder URLs found in ${filesScanned} file${filesScanned === 1 ? '' : 's'}.`);
159
+ return 0;
160
+ }
161
+
162
+ writeLine(stdout);
163
+ writeLine(stdout, `${findings.length} legacy URL${findings.length === 1 ? '' : 's'} found: ${safeCount} safe, ${manualCount} manual review.`);
164
+ if (options.write) writeLine(stdout, `${pendingWrites.length} file${pendingWrites.length === 1 ? '' : 's'} updated. Run again to verify the migrated URLs are gone.`);
165
+ else if (options.check) writeLine(stdout, 'CI check failed because legacy URLs remain.');
166
+ else writeLine(stdout, 'Dry run only. Re-run with --write to apply safe migrations.');
167
+ writeLine(stdout, 'Your source code is never uploaded to Placeholder.photo. No telemetry is collected.');
168
+ return 1;
169
+ } catch (error) {
170
+ writeLine(stderr, `Error: ${error instanceof Error ? error.message : String(error)}`);
171
+ return 2;
172
+ }
173
+ }
package/src/files.js ADDED
@@ -0,0 +1,194 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createGitIgnoreMatcher } from './gitignore.js';
5
+
6
+ const SOURCE_EXTENSIONS = new Set([
7
+ '.html', '.htm', '.css', '.scss', '.sass', '.less', '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx',
8
+ '.vue', '.svelte', '.php', '.md', '.mdx', '.json', '.jsonc', '.yaml', '.yml', '.xml', '.txt',
9
+ ]);
10
+
11
+ const EXCLUDED_DIRECTORIES = new Set([
12
+ '.git', 'node_modules', 'vendor', 'dist', 'build', 'coverage', '.cache', 'tmp', 'temp',
13
+ ]);
14
+
15
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024;
16
+ export const MAX_SOURCE_FILES = 100_000;
17
+
18
+ export function isWithin(root, candidate) {
19
+ const relative = path.relative(root, candidate);
20
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
21
+ }
22
+
23
+ export async function resolveScanTarget(inputPath) {
24
+ const absolute = path.resolve(inputPath);
25
+ const linkStatus = await lstat(absolute);
26
+ if (linkStatus.isSymbolicLink()) throw new Error('The selected target cannot be a symbolic link.');
27
+ if (!linkStatus.isDirectory() && !linkStatus.isFile()) throw new Error('The selected target must be a file or directory.');
28
+
29
+ const resolved = await realpath(absolute);
30
+ return {
31
+ target: resolved,
32
+ root: linkStatus.isDirectory() ? resolved : await realpath(path.dirname(resolved)),
33
+ isFile: linkStatus.isFile(),
34
+ };
35
+ }
36
+
37
+ async function rootIgnoreMatcher(root, isFile) {
38
+ if (isFile) return () => false;
39
+ try {
40
+ const contents = await readFile(path.join(root, '.gitignore'), 'utf8');
41
+ return createGitIgnoreMatcher(contents);
42
+ } catch (error) {
43
+ if (error?.code === 'ENOENT') return () => false;
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ export async function collectSourceFiles(scanTarget, excludedAbsolutePaths = new Set()) {
49
+ const ignored = await rootIgnoreMatcher(scanTarget.root, scanTarget.isFile);
50
+ const files = [];
51
+ const skipped = { binary: 0, large: 0, symlink: 0, ignored: 0, unsupported: 0, report: 0 };
52
+
53
+ const visit = async (absolute) => {
54
+ const linkStatus = await lstat(absolute);
55
+ if (linkStatus.isSymbolicLink()) {
56
+ skipped.symlink += 1;
57
+ return;
58
+ }
59
+
60
+ const resolved = await realpath(absolute);
61
+ if (!isWithin(scanTarget.root, resolved)) {
62
+ skipped.symlink += 1;
63
+ return;
64
+ }
65
+ if (excludedAbsolutePaths.has(resolved)) {
66
+ skipped.report += 1;
67
+ return;
68
+ }
69
+
70
+ const relative = path.relative(scanTarget.root, resolved).replaceAll(path.sep, '/');
71
+ if (linkStatus.isDirectory()) {
72
+ if (resolved !== scanTarget.root && (EXCLUDED_DIRECTORIES.has(path.basename(resolved).toLowerCase()) || ignored(relative))) {
73
+ skipped.ignored += 1;
74
+ return;
75
+ }
76
+ const entries = await readdir(resolved, { withFileTypes: true });
77
+ entries.sort((a, b) => a.name.localeCompare(b.name));
78
+ for (const entry of entries) await visit(path.join(resolved, entry.name));
79
+ return;
80
+ }
81
+
82
+ if (!linkStatus.isFile()) return;
83
+ if (ignored(relative)) {
84
+ skipped.ignored += 1;
85
+ return;
86
+ }
87
+ if (!SOURCE_EXTENSIONS.has(path.extname(resolved).toLowerCase())) {
88
+ skipped.unsupported += 1;
89
+ return;
90
+ }
91
+ if (linkStatus.size > MAX_FILE_BYTES) {
92
+ skipped.large += 1;
93
+ return;
94
+ }
95
+ files.push(resolved);
96
+ if (files.length > MAX_SOURCE_FILES) {
97
+ throw new Error(`Project contains more than the ${MAX_SOURCE_FILES} supported source files.`);
98
+ }
99
+ };
100
+
101
+ await visit(scanTarget.target);
102
+ return { files, skipped };
103
+ }
104
+
105
+ export async function readTextFile(absolute) {
106
+ const fileStatus = await stat(absolute);
107
+ if (fileStatus.size > MAX_FILE_BYTES) return { kind: 'large' };
108
+ const buffer = await readFile(absolute);
109
+ if (buffer.includes(0)) return { kind: 'binary' };
110
+
111
+ try {
112
+ const decoder = new TextDecoder('utf-8', { fatal: true });
113
+ const hasBom = buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf;
114
+ return {
115
+ kind: 'text',
116
+ content: decoder.decode(hasBom ? buffer.subarray(3) : buffer),
117
+ hasBom,
118
+ mode: fileStatus.mode,
119
+ };
120
+ } catch {
121
+ return { kind: 'binary' };
122
+ }
123
+ }
124
+
125
+ export function isGeneratedReport(content) {
126
+ const header = content.slice(0, 4096);
127
+ return /"tool"\s*:\s*"placeholder-photo-migrate"/.test(header)
128
+ && /"reportVersion"\s*:\s*1/.test(header);
129
+ }
130
+
131
+ export async function safeWriteTextFile(absolute, content, root, mode) {
132
+ const parent = await realpath(path.dirname(absolute));
133
+ const current = await lstat(absolute);
134
+ if (current.isSymbolicLink() || !current.isFile()) throw new Error(`Refusing to overwrite unsafe path: ${absolute}`);
135
+ if (!isWithin(root, parent) || !isWithin(root, await realpath(absolute))) {
136
+ throw new Error(`Refusing to write outside the selected project: ${absolute}`);
137
+ }
138
+
139
+ const temporary = path.join(parent, `.placeholder-photo-migrate-${process.pid}-${randomUUID()}.tmp`);
140
+ let handle;
141
+ try {
142
+ handle = await open(temporary, 'wx', mode & 0o777);
143
+ await handle.writeFile(content, 'utf8');
144
+ await handle.sync();
145
+ await handle.close();
146
+ handle = null;
147
+ await rename(temporary, absolute);
148
+ } finally {
149
+ if (handle) await handle.close().catch(() => {});
150
+ await rm(temporary, { force: true }).catch(() => {});
151
+ }
152
+ }
153
+
154
+ export async function resolveReportPath(reportPath, root) {
155
+ const absolute = path.resolve(reportPath);
156
+ const parent = path.dirname(absolute);
157
+ await mkdir(parent, { recursive: false }).catch((error) => {
158
+ if (error?.code !== 'EEXIST') throw error;
159
+ });
160
+ const resolvedParent = await realpath(parent);
161
+ if (!isWithin(root, resolvedParent)) throw new Error('The report path must stay inside the selected project.');
162
+ try {
163
+ const status = await lstat(absolute);
164
+ if (status.isSymbolicLink() || !status.isFile()) throw new Error('The report path is not a safe regular file.');
165
+ } catch (error) {
166
+ if (error?.code !== 'ENOENT') throw error;
167
+ }
168
+ return path.join(resolvedParent, path.basename(absolute));
169
+ }
170
+
171
+ export async function writeReportFile(absolute, contents, root) {
172
+ const parent = await realpath(path.dirname(absolute));
173
+ if (!isWithin(root, parent)) throw new Error('The report path must stay inside the selected project.');
174
+ try {
175
+ const current = await lstat(absolute);
176
+ if (current.isSymbolicLink() || !current.isFile()) throw new Error('Refusing to overwrite an unsafe report path.');
177
+ } catch (error) {
178
+ if (error?.code !== 'ENOENT') throw error;
179
+ }
180
+
181
+ const temporary = path.join(parent, `.placeholder-photo-migrate-report-${process.pid}-${randomUUID()}.tmp`);
182
+ let handle;
183
+ try {
184
+ handle = await open(temporary, 'wx', 0o600);
185
+ await handle.writeFile(contents, 'utf8');
186
+ await handle.sync();
187
+ await handle.close();
188
+ handle = null;
189
+ await rename(temporary, absolute);
190
+ } finally {
191
+ if (handle) await handle.close().catch(() => {});
192
+ await rm(temporary, { force: true }).catch(() => {});
193
+ }
194
+ }
@@ -0,0 +1,62 @@
1
+ function escapeRegex(character) {
2
+ return /[|\\{}()[\]^$+?.]/.test(character) ? `\\${character}` : character;
3
+ }
4
+
5
+ function globSource(pattern) {
6
+ let source = '';
7
+ for (let index = 0; index < pattern.length; index += 1) {
8
+ const character = pattern[index];
9
+ if (character === '*') {
10
+ if (pattern[index + 1] === '*') {
11
+ index += 1;
12
+ if (pattern[index + 1] === '/') {
13
+ index += 1;
14
+ source += '(?:.*/)?';
15
+ } else {
16
+ source += '.*';
17
+ }
18
+ } else {
19
+ source += '[^/]*';
20
+ }
21
+ } else if (character === '?') {
22
+ source += '[^/]';
23
+ } else {
24
+ source += escapeRegex(character);
25
+ }
26
+ }
27
+ return source;
28
+ }
29
+
30
+ function compileLine(rawLine) {
31
+ let line = rawLine.trim();
32
+ if (!line || line.startsWith('#')) return null;
33
+
34
+ let negated = false;
35
+ if (line.startsWith('!')) {
36
+ negated = true;
37
+ line = line.slice(1);
38
+ }
39
+ if (!line) return null;
40
+
41
+ const anchored = line.startsWith('/');
42
+ if (anchored) line = line.slice(1);
43
+ const directoryOnly = line.endsWith('/');
44
+ if (directoryOnly) line = line.slice(0, -1);
45
+ const containsSlash = line.includes('/');
46
+ const prefix = anchored || containsSlash ? '^' : '(?:^|.*/)';
47
+ const suffix = directoryOnly ? '(?:/.*)?$' : '(?:$|/.*$)';
48
+
49
+ return { negated, regex: new RegExp(`${prefix}${globSource(line)}${suffix}`) };
50
+ }
51
+
52
+ export function createGitIgnoreMatcher(contents = '') {
53
+ const rules = contents.split(/\r?\n/).map(compileLine).filter(Boolean);
54
+ return (relativePath) => {
55
+ const normalized = relativePath.replaceAll('\\', '/').replace(/^\.\//, '');
56
+ let ignored = false;
57
+ for (const rule of rules) {
58
+ if (rule.regex.test(normalized)) ignored = !rule.negated;
59
+ }
60
+ return ignored;
61
+ };
62
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { runCli } from './cli.js';
2
+ export { providers } from './providers/index.js';
3
+ export { applySafeFindings, scanSource } from './scanner.js';
4
+ export { VERSION } from './version.js';
package/src/options.js ADDED
@@ -0,0 +1,47 @@
1
+ export function parseOptions(argv) {
2
+ const options = {
3
+ write: false,
4
+ check: false,
5
+ help: false,
6
+ version: false,
7
+ report: null,
8
+ providers: [],
9
+ target: '.',
10
+ };
11
+ const positional = [];
12
+
13
+ for (let index = 0; index < argv.length; index += 1) {
14
+ const argument = argv[index];
15
+ if (argument === '--') {
16
+ positional.push(...argv.slice(index + 1));
17
+ break;
18
+ }
19
+ if (argument === '--write') options.write = true;
20
+ else if (argument === '--check') options.check = true;
21
+ else if (argument === '--help' || argument === '-h') options.help = true;
22
+ else if (argument === '--version' || argument === '-v') options.version = true;
23
+ else if (argument === '--provider') {
24
+ const value = argv[++index];
25
+ if (!value || value.startsWith('--')) throw new Error('--provider requires a provider name.');
26
+ options.providers.push(value);
27
+ } else if (argument.startsWith('--provider=')) {
28
+ options.providers.push(argument.slice('--provider='.length));
29
+ } else if (argument === '--report') {
30
+ const value = argv[++index];
31
+ if (!value || value.startsWith('--')) throw new Error('--report requires a file path.');
32
+ options.report = value;
33
+ } else if (argument.startsWith('--report=')) {
34
+ options.report = argument.slice('--report='.length);
35
+ } else if (argument.startsWith('-')) {
36
+ throw new Error(`Unknown option: ${argument}`);
37
+ } else {
38
+ positional.push(argument);
39
+ }
40
+ }
41
+
42
+ if (positional.length > 1) throw new Error('Provide exactly one project path.');
43
+ if (positional.length === 1) options.target = positional[0];
44
+ if (options.write && options.check) throw new Error('--write and --check cannot be used together.');
45
+ if (options.report === '') throw new Error('--report requires a file path.');
46
+ return options;
47
+ }
@@ -0,0 +1,126 @@
1
+ const SIZE_PATTERN = /^(\d{1,4})(?:x(\d{1,4}))?(?:\.(svg|png|jpe?g|gif|webp|avif))?$/i;
2
+ const COLOUR_PATTERN = /^([0-9a-f]{3}|[0-9a-f]{6})(?:\.(svg|png|jpe?g|gif|webp|avif))?$/i;
3
+
4
+ const VERIFIED_FORMATS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
5
+ const MAX_DIMENSION = 4096;
6
+ const MAX_PIXELS = 8_000_000;
7
+ const MAX_TEXT_BYTES = 720;
8
+ const MAX_TEXT_CHARACTERS = 180;
9
+
10
+ function manual(reason) {
11
+ return {
12
+ status: 'manual',
13
+ confidence: 'review',
14
+ reason,
15
+ replacement: null,
16
+ };
17
+ }
18
+
19
+ function safelyDecode(value) {
20
+ try {
21
+ return decodeURIComponent(value.replace(/\+/g, ' '));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function validateQuery(rawQuery) {
28
+ if (rawQuery === '' || rawQuery === '?') {
29
+ return null;
30
+ }
31
+
32
+ const parts = rawQuery.slice(1).split('&');
33
+ if (parts.length !== 1) {
34
+ return 'Only the verified text query parameter can be migrated automatically.';
35
+ }
36
+
37
+ const separator = parts[0].indexOf('=');
38
+ const rawKey = separator === -1 ? parts[0] : parts[0].slice(0, separator);
39
+ const rawValue = separator === -1 ? '' : parts[0].slice(separator + 1);
40
+ const key = safelyDecode(rawKey);
41
+ const value = safelyDecode(rawValue);
42
+
43
+ if (key !== 'text') {
44
+ return 'The query string is outside the verified legacy compatibility subset.';
45
+ }
46
+ if (value === null) {
47
+ return 'The text query contains malformed percent-encoding.';
48
+ }
49
+ if (value.includes('\0')) {
50
+ return 'The text query contains a null byte.';
51
+ }
52
+ if ([...value].length > MAX_TEXT_CHARACTERS || Buffer.byteLength(value, 'utf8') > MAX_TEXT_BYTES) {
53
+ return 'The text query exceeds Placeholder.photo compatibility limits.';
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function parsePath(rawPath) {
59
+ if (!rawPath || rawPath === '/') {
60
+ return manual('No legacy placeholder image dimensions were found in the path.');
61
+ }
62
+ if (rawPath.includes('%') || rawPath.includes('\\') || rawPath.includes('//')) {
63
+ return manual('Encoded or non-canonical path separators require manual review.');
64
+ }
65
+
66
+ const trimmed = rawPath.replace(/^\//, '').replace(/\/$/, '');
67
+ const segments = trimmed.split('/');
68
+ if (segments[0]?.toLowerCase() === 'img') {
69
+ segments.shift();
70
+ }
71
+ if (segments.length < 1 || segments.length > 3) {
72
+ return manual('The path is not a verified classic placeholder pattern.');
73
+ }
74
+
75
+ const size = SIZE_PATTERN.exec(segments[0]);
76
+ if (!size) {
77
+ return manual('The dimension segment is malformed or unsupported.');
78
+ }
79
+
80
+ const width = Number.parseInt(size[1], 10);
81
+ const height = size[2] ? Number.parseInt(size[2], 10) : width;
82
+ if (width < 1 || height < 1 || width > MAX_DIMENSION || height > MAX_DIMENSION || width * height > MAX_PIXELS) {
83
+ return manual('The dimensions exceed Placeholder.photo compatibility limits.');
84
+ }
85
+
86
+ const extensions = [];
87
+ if (size[3]) extensions.push(size[3].toLowerCase());
88
+ for (const colourSegment of segments.slice(1)) {
89
+ const colour = COLOUR_PATTERN.exec(colourSegment);
90
+ if (!colour) {
91
+ return manual('A colour segment is outside the verified 3- or 6-digit hexadecimal syntax.');
92
+ }
93
+ if (colour[2]) extensions.push(colour[2].toLowerCase());
94
+ }
95
+
96
+ if (extensions.length > 1) {
97
+ return manual('Multiple format suffixes are ambiguous and require manual review.');
98
+ }
99
+ if (extensions.length === 1 && !VERIFIED_FORMATS.has(extensions[0])) {
100
+ return manual('This format is supported elsewhere by Placeholder.photo but is not in the verified legacy migration matrix.');
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ export function analyseClassicCandidate(candidate, { exactHost = true } = {}) {
107
+ if (!exactHost) {
108
+ return manual('This hostname variant is not covered by the verified compatibility rules.');
109
+ }
110
+ if (candidate.port) {
111
+ return manual('URLs with an explicit port require manual review.');
112
+ }
113
+
114
+ const pathProblem = parsePath(candidate.path);
115
+ if (pathProblem) return pathProblem;
116
+
117
+ const queryProblem = validateQuery(candidate.query);
118
+ if (queryProblem) return manual(queryProblem);
119
+
120
+ return {
121
+ status: 'safe',
122
+ confidence: 'verified',
123
+ reason: null,
124
+ replacement: `https://placeholder.photo${candidate.suffix}`,
125
+ };
126
+ }
@@ -0,0 +1,32 @@
1
+ import { placeholdItProvider } from './placehold-it.js';
2
+ import { placeholderComProvider } from './placeholder-com.js';
3
+ import { viaPlaceholderProvider } from './via-placeholder.js';
4
+
5
+ export const providers = [viaPlaceholderProvider, placeholdItProvider, placeholderComProvider];
6
+
7
+ export const providerAliases = new Map([
8
+ ['via-placeholder', 'via-placeholder'],
9
+ ['via.placeholder.com', 'via-placeholder'],
10
+ ['placehold-it', 'placehold-it'],
11
+ ['placehold.it', 'placehold-it'],
12
+ ['placeholder-com', 'placeholder-com'],
13
+ ['placeholder.com', 'placeholder-com'],
14
+ ]);
15
+
16
+ export function normalizeProviderIds(values) {
17
+ if (values.length === 0) return new Set(providers.map((provider) => provider.id));
18
+
19
+ const selected = new Set();
20
+ for (const value of values.flatMap((item) => item.split(','))) {
21
+ const normalized = providerAliases.get(value.trim().toLowerCase());
22
+ if (!normalized) {
23
+ throw new Error(`Unknown provider: ${value}. Use via-placeholder, placehold-it, or placeholder-com.`);
24
+ }
25
+ selected.add(normalized);
26
+ }
27
+ return selected;
28
+ }
29
+
30
+ export function providerForHostname(hostname) {
31
+ return providers.find((provider) => provider.hosts.has(hostname.toLowerCase())) ?? null;
32
+ }
@@ -0,0 +1,10 @@
1
+ import { analyseClassicCandidate } from './common.js';
2
+
3
+ export const placeholdItProvider = {
4
+ id: 'placehold-it',
5
+ label: 'placehold.it',
6
+ hosts: new Set(['placehold.it', 'www.placehold.it']),
7
+ analyse(candidate) {
8
+ return analyseClassicCandidate(candidate, { exactHost: candidate.hostname === 'placehold.it' });
9
+ },
10
+ };
@@ -0,0 +1,10 @@
1
+ import { analyseClassicCandidate } from './common.js';
2
+
3
+ export const placeholderComProvider = {
4
+ id: 'placeholder-com',
5
+ label: 'legacy placeholder.com',
6
+ hosts: new Set(['placeholder.com', 'www.placeholder.com']),
7
+ analyse(candidate) {
8
+ return analyseClassicCandidate(candidate, { exactHost: candidate.hostname === 'placeholder.com' });
9
+ },
10
+ };
@@ -0,0 +1,10 @@
1
+ import { analyseClassicCandidate } from './common.js';
2
+
3
+ export const viaPlaceholderProvider = {
4
+ id: 'via-placeholder',
5
+ label: 'via.placeholder.com',
6
+ hosts: new Set(['via.placeholder.com']),
7
+ analyse(candidate) {
8
+ return analyseClassicCandidate(candidate);
9
+ },
10
+ };
package/src/report.js ADDED
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ import { VERSION } from './version.js';
3
+
4
+ export function createReport({ mode, root, target, filesScanned, filesChanged, findings, skipped }) {
5
+ const safe = findings.filter((finding) => finding.status === 'safe').length;
6
+ const manual = findings.length - safe;
7
+ return {
8
+ tool: 'placeholder-photo-migrate',
9
+ reportVersion: 1,
10
+ version: VERSION,
11
+ generatedAt: new Date().toISOString(),
12
+ mode,
13
+ target: path.relative(root, target).replaceAll(path.sep, '/') || '.',
14
+ privacy: 'Local-only scan. No source code or telemetry was transmitted.',
15
+ summary: {
16
+ filesScanned,
17
+ filesChanged,
18
+ legacyUrls: findings.length,
19
+ safeMigrations: safe,
20
+ manualReview: manual,
21
+ skipped,
22
+ },
23
+ findings: findings.map((finding) => ({
24
+ file: finding.file,
25
+ line: finding.line,
26
+ column: finding.column,
27
+ provider: finding.provider,
28
+ status: finding.status,
29
+ confidence: finding.confidence,
30
+ original: finding.original,
31
+ replacement: finding.replacement,
32
+ reason: finding.reason,
33
+ })),
34
+ };
35
+ }
package/src/scanner.js ADDED
@@ -0,0 +1,84 @@
1
+ import { providerForHostname } from './providers/index.js';
2
+
3
+ const LEGACY_URL_PATTERN = /(?:(?<![A-Za-z0-9_+.-])https?:\/\/|(?<![A-Za-z0-9_+./:-])\/\/)(?:via\.placeholder\.com|placehold\.it|www\.placehold\.it|placeholder\.com|www\.placeholder\.com)(?::\d{1,5})?(?![A-Za-z0-9._:-])(?:[/?#][^\s"'<>`\\)\]}]*)?/giu;
4
+ const CANDIDATE_PATTERN = /^(?:(https?):)?\/\/([^/?#:]+)(?::(\d{1,5}))?(.*)$/iu;
5
+
6
+ function lineStartsFor(content) {
7
+ const starts = [0];
8
+ for (let index = 0; index < content.length; index += 1) {
9
+ if (content.charCodeAt(index) === 10) starts.push(index + 1);
10
+ }
11
+ return starts;
12
+ }
13
+
14
+ function sourcePosition(lineStarts, offset) {
15
+ let low = 0;
16
+ let high = lineStarts.length - 1;
17
+ while (low <= high) {
18
+ const middle = (low + high) >> 1;
19
+ if (lineStarts[middle] <= offset) low = middle + 1;
20
+ else high = middle - 1;
21
+ }
22
+ return { line: high + 1, column: offset - lineStarts[high] + 1 };
23
+ }
24
+
25
+ function parseCandidate(raw) {
26
+ const match = CANDIDATE_PATTERN.exec(raw);
27
+ if (!match) return null;
28
+
29
+ const suffix = match[4] || '';
30
+ const fragmentAt = suffix.indexOf('#');
31
+ const beforeFragment = fragmentAt === -1 ? suffix : suffix.slice(0, fragmentAt);
32
+ const queryAt = beforeFragment.indexOf('?');
33
+ const path = queryAt === -1 ? beforeFragment : beforeFragment.slice(0, queryAt);
34
+ const query = queryAt === -1 ? '' : beforeFragment.slice(queryAt);
35
+
36
+ return {
37
+ raw,
38
+ protocol: match[1]?.toLowerCase() ?? null,
39
+ hostname: match[2].toLowerCase(),
40
+ port: match[3] ?? null,
41
+ suffix,
42
+ path,
43
+ query,
44
+ };
45
+ }
46
+
47
+ export function scanSource(content, selectedProviderIds) {
48
+ const lineStarts = lineStartsFor(content);
49
+ const findings = [];
50
+ LEGACY_URL_PATTERN.lastIndex = 0;
51
+
52
+ for (const match of content.matchAll(LEGACY_URL_PATTERN)) {
53
+ const candidate = parseCandidate(match[0]);
54
+ if (!candidate) continue;
55
+ const provider = providerForHostname(candidate.hostname);
56
+ if (!provider || !selectedProviderIds.has(provider.id)) continue;
57
+
58
+ const result = provider.analyse(candidate);
59
+ const position = sourcePosition(lineStarts, match.index);
60
+ findings.push({
61
+ provider: provider.id,
62
+ providerLabel: provider.label,
63
+ status: result.status,
64
+ confidence: result.confidence,
65
+ reason: result.reason,
66
+ original: match[0],
67
+ replacement: result.replacement,
68
+ start: match.index,
69
+ end: match.index + match[0].length,
70
+ ...position,
71
+ });
72
+ }
73
+
74
+ return findings;
75
+ }
76
+
77
+ export function applySafeFindings(content, findings) {
78
+ const safe = findings.filter((finding) => finding.status === 'safe').sort((a, b) => b.start - a.start);
79
+ let migrated = content;
80
+ for (const finding of safe) {
81
+ migrated = `${migrated.slice(0, finding.start)}${finding.replacement}${migrated.slice(finding.end)}`;
82
+ }
83
+ return migrated;
84
+ }
package/src/version.js ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = '1.0.0';