cornhsu-polymigrate 1.1.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/README.md +138 -0
- package/bin/polymigrate.js +68 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# PolyMigrate
|
|
2
|
+
|
|
3
|
+
[](https://www.nuget.org/packages/Cornhsu.PolyMigrate)
|
|
4
|
+
[](https://www.nuget.org/packages/Cornhsu.PolyMigrate)
|
|
5
|
+
[](https://github.com/HSU-YU-MING/cornhsu-polymigrate/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
> **The i18n-first static-site migrator** — the only site migrator that pairs your multilingual pages automatically.
|
|
9
|
+
|
|
10
|
+
**[Project write-up](https://cornhsu.com/polymigrate) · [NuGet](https://www.nuget.org/packages/Cornhsu.PolyMigrate) · [繁體中文說明](README.zh-Hant.md) · MIT**
|
|
11
|
+
|
|
12
|
+
PolyMigrate turns legacy dynamic sites (old PHP sites and the like) into clean, static-site-ready
|
|
13
|
+
Markdown — and it treats multilingual content as a first-class concern, not an afterthought.
|
|
14
|
+
Config-driven, fully offline-rerunnable, built on .NET.
|
|
15
|
+
|
|
16
|
+
**Status: 1.0.** The extraction pipeline, pairing, verification, thumbnails and orphan-page
|
|
17
|
+
recovery are complete and validated against a real full-site migration (see below). The CLI
|
|
18
|
+
surface and the Phase output contracts are now stable: new features bump the minor version,
|
|
19
|
+
fixes bump the patch version.
|
|
20
|
+
|
|
21
|
+
## Why
|
|
22
|
+
|
|
23
|
+
Every multilingual institution site (governments, universities, NGOs, religious organizations)
|
|
24
|
+
faces the same painful migration step: matching up the language versions of every page by hand.
|
|
25
|
+
No existing tool solves this — general-purpose scrapers extract pages one at a time and leave
|
|
26
|
+
the pairing to you. PolyMigrate:
|
|
27
|
+
|
|
28
|
+
- **pairs automatically** where filenames are symmetric (`/ch/news/x` ↔ `/en/news/x` share a
|
|
29
|
+
`translation_key`),
|
|
30
|
+
- **suggests pairs heuristically** where they are not — shared photo albums, normalized dates
|
|
31
|
+
hidden in slugs (`20240121` vs `01212024`), title similarity,
|
|
32
|
+
- **honestly reports what it cannot pair**, producing a review-ready gap inventory instead of
|
|
33
|
+
guessing wrong.
|
|
34
|
+
|
|
35
|
+
Languages are not limited to two: declare any number in `lang_map` and every output
|
|
36
|
+
(frontmatter, inventories, pairing) expands accordingly. All locale output is standard BCP-47.
|
|
37
|
+
|
|
38
|
+
## Battle-tested defaults
|
|
39
|
+
|
|
40
|
+
The extraction pipeline bakes in fixes for real problems found during a real migration —
|
|
41
|
+
things generic tools and LLM extractors silently get wrong:
|
|
42
|
+
|
|
43
|
+
| Real-world pit | Built-in handling |
|
|
44
|
+
|---|---|
|
|
45
|
+
| Phone photos sideways/upside-down in thumbnails | EXIF auto-orientation before resizing |
|
|
46
|
+
| Titles with colons / numeric slugs with leading zeros | YAML-library escaping, forced quoting |
|
|
47
|
+
| `%20` double-encoding in image paths | decoded on disk, single-encoded in URLs |
|
|
48
|
+
| Markdown converters dropping videos / iframes / PDFs | placeholder round-trip keeps embeds in place |
|
|
49
|
+
| `<title>` polluted with dates and site name | body-first title extraction + configurable cleanup |
|
|
50
|
+
| Mixed date formats (`YYYYMMDD` / `MMDDYYYY` / `DDMMYYYY`) | all recognized and normalized |
|
|
51
|
+
| Old articles removed from indexes but still served | orphan probing (per-day URL candidates + suffix variants) |
|
|
52
|
+
| Broken images on the source site | detected, recorded, never blocks the run |
|
|
53
|
+
| Bot protection (JS cookie challenge → 409) | declarative cookie workaround in config |
|
|
54
|
+
| Legacy encodings (Big5, GB2312, …) | declared or defaulted per site |
|
|
55
|
+
|
|
56
|
+
## Case study: full temple-site migration
|
|
57
|
+
|
|
58
|
+
PolyMigrate's pipeline is the productized version of a completed real migration
|
|
59
|
+
(a bilingual Buddhist temple site, Chinese/English):
|
|
60
|
+
|
|
61
|
+
- **516 pages** mirrored and extracted, **4.6 GB** of media
|
|
62
|
+
- **281 translation keys**; **231 bilingual articles paired automatically** by symmetric paths
|
|
63
|
+
- built-in verifier: **1,269 internal links + 4,116 media references checked — 0 errors**
|
|
64
|
+
- 13 orphaned articles recovered via date probing; 141 EXIF-rotated photos fixed in thumbnails
|
|
65
|
+
|
|
66
|
+
The original Python prototype's output was used as the golden baseline while porting: 466/516
|
|
67
|
+
extracted bodies are byte-identical after whitespace normalization, and the remainder are
|
|
68
|
+
render-equivalent or strictly more faithful.
|
|
69
|
+
|
|
70
|
+
Two more numbers from that run:
|
|
71
|
+
|
|
72
|
+
- **Re-runs are ~7× faster**: media hashes are cached by `(size, mtime)`, taking a full re-run
|
|
73
|
+
of the 4.6 GB site from **30.1 s down to 4.6 s**.
|
|
74
|
+
- **The output is deployable as-is**: `redirect_map` is auto-filled with the new paths, and
|
|
75
|
+
PolyMigrate emits both an **nginx conf** and a **Netlify `_redirects`** file — turning a
|
|
76
|
+
half-day of hand-written 301s into copying one file.
|
|
77
|
+
|
|
78
|
+
## Install & use
|
|
79
|
+
|
|
80
|
+
Two channels — pick whichever runtime you already have. **Same tool, same behaviour.**
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
npx cornhsu-polymigrate extract site.yaml # Node — no .NET install needed
|
|
84
|
+
dotnet tool install -g Cornhsu.PolyMigrate # .NET
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
> The npm build ships a self-contained native binary and only downloads the one
|
|
88
|
+
> matching your platform (win32-x64 / linux-x64 / darwin-x64 / darwin-arm64).
|
|
89
|
+
> Migrating a site is usually a one-off job — installing a whole SDK for a single
|
|
90
|
+
> run is friction most people won't accept, so the tool meets you where you are.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
dotnet tool install -g Cornhsu.PolyMigrate
|
|
94
|
+
polymigrate extract site.yaml # mirror HTML -> frontmatter Markdown + inventories
|
|
95
|
+
polymigrate verify out/ # link/media/frontmatter audit, CI-friendly exit codes
|
|
96
|
+
polymigrate thumbs site.yaml # EXIF-corrected, width-capped thumbnails
|
|
97
|
+
polymigrate probe-orphans site.yaml --section news --years 2021-2023
|
|
98
|
+
polymigrate fetch-orphans site.yaml --section news
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
One YAML config per site describes everything site-specific — see
|
|
102
|
+
[examples/ibps-austin.yaml](examples/ibps-austin.yaml) for a fully-annotated real example.
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
config_version: 1
|
|
106
|
+
site:
|
|
107
|
+
base_url: https://legacy.example.org
|
|
108
|
+
url_pattern:
|
|
109
|
+
lang_map: { ch: zh-Hant, en: en } # any number of languages
|
|
110
|
+
default_lang: zh-Hant
|
|
111
|
+
strip_extensions: [.php]
|
|
112
|
+
extract:
|
|
113
|
+
content: "section[id]:not(#header):not(#footer)"
|
|
114
|
+
pairing:
|
|
115
|
+
strategy: symmetric_path
|
|
116
|
+
fallback: [shared_media, date, title_similarity]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Layout
|
|
120
|
+
|
|
121
|
+
| Path | Contents |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `src/PolyMigrate.Core` | extraction / pairing / verification library (NuGet: `Cornhsu.PolyMigrate.Core`) |
|
|
124
|
+
| `src/PolyMigrate.Cli` | the `polymigrate` CLI (NuGet tool package: `Cornhsu.PolyMigrate`) |
|
|
125
|
+
| `tests/` | 119 unit/integration tests + an offline fixture site with golden-file baselines |
|
|
126
|
+
| `docs/contracts.md` | file-format contracts between pipeline phases |
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
dotnet build
|
|
132
|
+
dotnet test
|
|
133
|
+
dotnet run --project src/PolyMigrate.Cli -- --help
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
License: [MIT](LICENSE). All dependencies are MIT/BSD/Apache-2.0
|
|
137
|
+
(imaging via **Magick.NET**; ImageSharp was dropped when its 4.x line began requiring a
|
|
138
|
+
license key at build time).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npm 通路的啟動腳本。
|
|
3
|
+
//
|
|
4
|
+
// 為什麼要有這一層(而不是直接把執行檔當 bin):
|
|
5
|
+
// npm 靠 optionalDependencies 的 os/cpu 只下載使用者當前平台的那一包,
|
|
6
|
+
// 但執行時仍需要解析出它實際被裝到哪裡。
|
|
7
|
+
//
|
|
8
|
+
// 為什麼要有 npm 通路:PolyMigrate 的使用者是「要把舊網站搬成靜態站的人」,
|
|
9
|
+
// 那些人多半在 Hugo / Eleventy / Astro / Next.js 的生態裡,手邊有 Node、
|
|
10
|
+
// 不一定有 .NET SDK。而且搬站是一次性任務 —— 為了用一次而裝一整套 SDK,
|
|
11
|
+
// 摩擦成本高到多數人會直接放棄。
|
|
12
|
+
//
|
|
13
|
+
// 刻意不做 postinstall 下載:那會被 `npm ci --ignore-scripts` 擋掉,
|
|
14
|
+
// 而不少公司的 CI 預設就是關腳本的。
|
|
15
|
+
|
|
16
|
+
"use strict";
|
|
17
|
+
|
|
18
|
+
const { spawnSync } = require("node:child_process");
|
|
19
|
+
const path = require("node:path");
|
|
20
|
+
|
|
21
|
+
// 平台 → 子套件名 / 執行檔名
|
|
22
|
+
const TARGETS = {
|
|
23
|
+
"win32 x64": { pkg: "@cornhsu/polymigrate-win32-x64", bin: "polymigrate.exe" },
|
|
24
|
+
"linux x64": { pkg: "@cornhsu/polymigrate-linux-x64", bin: "polymigrate" },
|
|
25
|
+
"darwin x64": { pkg: "@cornhsu/polymigrate-darwin-x64", bin: "polymigrate" },
|
|
26
|
+
"darwin arm64": { pkg: "@cornhsu/polymigrate-darwin-arm64", bin: "polymigrate" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const key = `${process.platform} ${process.arch}`;
|
|
30
|
+
const target = TARGETS[key];
|
|
31
|
+
|
|
32
|
+
if (!target) {
|
|
33
|
+
console.error(
|
|
34
|
+
`Cornhsu.PolyMigrate 沒有 ${key} 的預先建置版本。\n` +
|
|
35
|
+
`目前支援:${Object.keys(TARGETS).join("、")}\n` +
|
|
36
|
+
`其他平台請改用 .NET 版本:dotnet tool install -g Cornhsu.PolyMigrate`
|
|
37
|
+
);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let binary;
|
|
42
|
+
try {
|
|
43
|
+
// 從子套件的 package.json 反推安裝位置,不用猜 node_modules 的層級
|
|
44
|
+
// (pnpm / yarn 的實體結構與 npm 不同,require.resolve 才可靠)
|
|
45
|
+
const manifest = require.resolve(`${target.pkg}/package.json`);
|
|
46
|
+
binary = path.join(path.dirname(manifest), "bin", target.bin);
|
|
47
|
+
} catch {
|
|
48
|
+
console.error(
|
|
49
|
+
`找不到平台套件 ${target.pkg}。\n` +
|
|
50
|
+
`可能是安裝時加了 --no-optional,或安裝過程中斷。\n` +
|
|
51
|
+
`請重新安裝:npm install cornhsu-polymigrate`
|
|
52
|
+
);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
|
|
57
|
+
|
|
58
|
+
if (result.error) {
|
|
59
|
+
console.error(`無法執行 ${binary}:${result.error.message}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 被訊號中斷時 status 會是 null;轉成慣例的 128+signal,別讓 CI 誤判為成功
|
|
64
|
+
if (result.status === null && result.signal) {
|
|
65
|
+
process.exit(128 + (require("node:os").constants.signals[result.signal] ?? 0));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.exit(result.status ?? 0);
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cornhsu-polymigrate",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "The i18n-first static-site migrator — the only one that pairs your multilingual pages automatically. Mirrors a legacy site into frontmatter Markdown, auto-matches language versions, and honestly lists what it could not pair.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"i18n",
|
|
7
|
+
"internationalization",
|
|
8
|
+
"multilingual",
|
|
9
|
+
"site-migration",
|
|
10
|
+
"static-site",
|
|
11
|
+
"markdown",
|
|
12
|
+
"web-scraping",
|
|
13
|
+
"content-migration",
|
|
14
|
+
"hugo",
|
|
15
|
+
"eleventy",
|
|
16
|
+
"astro"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://cornhsu.com/polymigrate",
|
|
19
|
+
"bugs": "https://github.com/HSU-YU-MING/cornhsu-polymigrate/issues",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/HSU-YU-MING/cornhsu-polymigrate.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "許彧銘 Hsu Yu-Ming (https://cornhsu.com/)",
|
|
26
|
+
"type": "commonjs",
|
|
27
|
+
"bin": {
|
|
28
|
+
"polymigrate": "bin/polymigrate.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin/polymigrate.js",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"optionalDependencies": {
|
|
38
|
+
"@cornhsu/polymigrate-win32-x64": "1.1.0",
|
|
39
|
+
"@cornhsu/polymigrate-linux-x64": "1.1.0",
|
|
40
|
+
"@cornhsu/polymigrate-darwin-x64": "1.1.0",
|
|
41
|
+
"@cornhsu/polymigrate-darwin-arm64": "1.1.0"
|
|
42
|
+
}
|
|
43
|
+
}
|