@vp-tw/ptt-link-switcher 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vp-tw contributors
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,18 @@
1
+ # @vp-tw/ptt-link-switcher
2
+
3
+ Parse and convert PTT article links across supported readers—entirely offline.
4
+
5
+ ```ts
6
+ import { generateProviderLinks, parsePttInput } from "@vp-tw/ptt-link-switcher";
7
+
8
+ const result = parsePttInput("#1eMgfVyi", { board: "Browsers" });
9
+
10
+ if (result.ok) {
11
+ const links = generateProviderLinks(result.article);
12
+ console.log(links);
13
+ }
14
+ ```
15
+
16
+ Provider support is adapter-based. Pass custom adapters to `parsePttInput` and `generateProviderLinks` to extend parsing and URL generation without changing the built-in registry.
17
+
18
+ The package is framework-free and does not access the DOM, network, or storage.
@@ -0,0 +1,86 @@
1
+ //#region src/aid.d.ts
2
+ declare function isValidAid(value: string): boolean;
3
+ declare function aidToArticleId(aid: string): string | null;
4
+ declare function articleIdToAid(articleId: string): string | null;
5
+ //#endregion
6
+ //#region src/types.d.ts
7
+ interface PttArticleRef {
8
+ readonly board: string;
9
+ readonly articleId: string;
10
+ }
11
+ type ProviderId = "official" | "beptt" | "moptt" | "pttweb";
12
+ interface PttUrl {
13
+ readonly hostname: string;
14
+ readonly pathname: string;
15
+ readonly toString: () => string;
16
+ }
17
+ interface ProviderAdapter<TId extends string = string> {
18
+ readonly id: TId;
19
+ readonly label: string;
20
+ readonly hosts: readonly string[];
21
+ readonly parseUrl: (url: PttUrl) => PttArticleRef | null;
22
+ readonly toUrl: (article: PttArticleRef) => string;
23
+ }
24
+ type ParseSource = {
25
+ readonly kind: "aid";
26
+ } | {
27
+ readonly kind: "url";
28
+ readonly providerId: string;
29
+ readonly url: string;
30
+ };
31
+ type ParseError = {
32
+ readonly code: "empty_input";
33
+ readonly message: string;
34
+ } | {
35
+ readonly code: "invalid_aid";
36
+ readonly message: string;
37
+ } | {
38
+ readonly aid: string;
39
+ readonly code: "missing_board";
40
+ readonly message: string;
41
+ } | {
42
+ readonly code: "unsupported_provider";
43
+ readonly host: string;
44
+ readonly message: string;
45
+ } | {
46
+ readonly code: "unrecognized_input";
47
+ readonly message: string;
48
+ };
49
+ type ParseResult = {
50
+ readonly article: PttArticleRef;
51
+ readonly ok: true;
52
+ readonly source: ParseSource;
53
+ } | {
54
+ readonly error: ParseError;
55
+ readonly ok: false;
56
+ };
57
+ interface ParseOptions {
58
+ readonly board?: string;
59
+ readonly providers?: readonly ProviderAdapter[];
60
+ }
61
+ interface ProviderLink<TId extends string = string> {
62
+ readonly id: TId;
63
+ readonly label: string;
64
+ readonly url: string;
65
+ }
66
+ //#endregion
67
+ //#region src/parse.d.ts
68
+ declare function parsePttInput(input: string, options?: ParseOptions): ParseResult;
69
+ declare function tryParsePttInput(input: string, options?: ParseOptions): PttArticleRef | null;
70
+ //#endregion
71
+ //#region src/providers.d.ts
72
+ declare const officialProvider: ProviderAdapter<"official">;
73
+ declare const bepttProvider: ProviderAdapter<"beptt">;
74
+ declare const mopttProvider: ProviderAdapter<"moptt">;
75
+ declare const pttwebProvider: ProviderAdapter<"pttweb">;
76
+ declare const providers: readonly [ProviderAdapter<"official">, ProviderAdapter<"beptt">, ProviderAdapter<"moptt">, ProviderAdapter<"pttweb">];
77
+ declare function getProvider<TAdapter extends ProviderAdapter>(adapters: readonly TAdapter[], id: string): TAdapter | undefined;
78
+ declare function generateProviderLinks(article: PttArticleRef): ProviderLink<ProviderId>[];
79
+ declare function generateProviderLinks<TAdapter extends ProviderAdapter>(article: PttArticleRef, adapters: readonly TAdapter[]): ProviderLink<TAdapter["id"]>[];
80
+ //#endregion
81
+ //#region src/validation.d.ts
82
+ declare function normalizeArticleRef(board: string, articleId: string): PttArticleRef | null;
83
+ declare function isValidBoard(board: string): boolean;
84
+ //#endregion
85
+ export { type ParseError, type ParseOptions, type ParseResult, type ParseSource, type ProviderAdapter, type ProviderId, type ProviderLink, type PttArticleRef, type PttUrl, aidToArticleId, articleIdToAid, bepttProvider, generateProviderLinks, getProvider, isValidAid, isValidBoard, mopttProvider, normalizeArticleRef, officialProvider, parsePttInput, providers, pttwebProvider, tryParsePttInput };
86
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/aid.ts","../src/types.ts","../src/parse.ts","../src/providers.ts","../src/validation.ts"],"mappings":";iBAIgB,WAAW;iBAIX,eAAe;iBAuBf,eAAe;;;UC/Bd;WACN;WACA;;KAGC;UAEK;WACN;WACA;WACA;;UAGM,gBAAgB;WACtB,IAAI;WACJ;WACA;WACA,WAAW,KAAK,WAAW;WAC3B,QAAQ,SAAS;;KAGhB;WACG;;WACA;WAAsB;WAA6B;;KAEtD;WACG;WAA8B;;WAC9B;WAA8B;;WAE9B;WACA;WACA;;WAGA;WACA;WACA;;WAEA;WAAqC;;KAExC;WAEG,SAAS;WACT;WACA,QAAQ;;WAER,OAAO;WAAqB;;UAE1B;WACN;WACA,qBAAqB;;UAGf,aAAa;WACnB,IAAI;WACJ;WACA;;;;iBCXK,cAAc,eAAe,UAAS,eAAoB;iBA8F1D,iBAAiB,eAAe,UAAU,eAAe;;;cCjH5D,kBAAkB;cASlB,eAAe;cAcf,eAAe;cAcf,gBAAgB;cAShB,qBACX,6BACA,0BACA,0BACA;iBAGc,YAAY,iBAAiB,iBAC3C,mBAAmB,YACnB,aACC;iBAIa,sBAAsB,SAAS,gBAAgB,aAAa;iBAC5D,sBAAsB,iBAAiB,iBACrD,SAAS,eACT,mBAAmB,aAClB,aAAa;;;iBCrFA,oBAAoB,eAAe,oBAAoB;iBAgBvD,aAAa"}
package/dist/index.js ADDED
@@ -0,0 +1,241 @@
1
+ //#region src/aid.ts
2
+ const AID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_";
3
+ const AID_PATTERN = /^[0-9A-Za-z_-]{8}$/;
4
+ const ARTICLE_ID_PATTERN$1 = /^(M|G)\.(\d+)\.A(?:\.([0-9A-F]{3}))?$/;
5
+ function isValidAid(value) {
6
+ return AID_PATTERN.test(value);
7
+ }
8
+ function aidToArticleId(aid) {
9
+ const normalized = aid.startsWith("#") ? aid.slice(1) : aid;
10
+ if (!isValidAid(normalized)) return null;
11
+ let timestamp = 0;
12
+ for (const character of normalized.slice(0, 6)) {
13
+ const index = AID_ALPHABET.indexOf(character);
14
+ if (index < 0) return null;
15
+ timestamp = timestamp * 64 + index;
16
+ }
17
+ let checksum = 0;
18
+ for (const character of normalized.slice(6)) {
19
+ const index = AID_ALPHABET.indexOf(character);
20
+ if (index < 0) return null;
21
+ checksum = checksum * 64 + index;
22
+ }
23
+ const checksumPart = checksum === 0 ? "" : `.${checksum.toString(16).toUpperCase().padStart(3, "0")}`;
24
+ return `M.${timestamp}.A${checksumPart}`;
25
+ }
26
+ function articleIdToAid(articleId) {
27
+ const match = ARTICLE_ID_PATTERN$1.exec(articleId.toUpperCase());
28
+ if (!match || match[1] !== "M" || match[2] === void 0) return null;
29
+ let timestamp = Number(match[2]);
30
+ let checksum = match[3] === void 0 ? 0 : Number.parseInt(match[3], 16);
31
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0 || checksum > 4095) return null;
32
+ let encodedTimestamp = "";
33
+ for (let index = 0; index < 6; index += 1) {
34
+ encodedTimestamp = AID_ALPHABET[timestamp % 64] + encodedTimestamp;
35
+ timestamp = Math.floor(timestamp / 64);
36
+ }
37
+ let encodedChecksum = "";
38
+ for (let index = 0; index < 2; index += 1) {
39
+ encodedChecksum = AID_ALPHABET[checksum % 64] + encodedChecksum;
40
+ checksum = Math.floor(checksum / 64);
41
+ }
42
+ return `#${encodedTimestamp}${encodedChecksum}`;
43
+ }
44
+ //#endregion
45
+ //#region src/validation.ts
46
+ const BOARD_PATTERN = /^[A-Za-z0-9_.-]+$/;
47
+ const ARTICLE_ID_PATTERN = /^(M|G)\.\d+\.A(?:\.[0-9A-F]{3})?$/;
48
+ function normalizeArticleRef(board, articleId) {
49
+ const normalizedBoard = board.trim();
50
+ const normalizedArticleId = articleId.trim().toUpperCase();
51
+ if (normalizedBoard.length === 0 || normalizedBoard.length > 64 || !BOARD_PATTERN.test(normalizedBoard) || !ARTICLE_ID_PATTERN.test(normalizedArticleId)) return null;
52
+ return {
53
+ articleId: normalizedArticleId,
54
+ board: normalizedBoard
55
+ };
56
+ }
57
+ function isValidBoard(board) {
58
+ return normalizeArticleRef(board, "M.0.A") !== null;
59
+ }
60
+ //#endregion
61
+ //#region src/providers.ts
62
+ function decodePathPart(value) {
63
+ try {
64
+ return decodeURIComponent(value);
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+ function parsePttStylePath(pathname) {
70
+ const match = /^\/bbs\/([^/]+)\/((?:M|G)\.\d+\.A(?:\.[0-9A-Fa-f]{3})?)(?:\.html)?\/?$/.exec(pathname);
71
+ if (!match || match[1] === void 0 || match[2] === void 0) return null;
72
+ const board = decodePathPart(match[1]);
73
+ return board === null ? null : normalizeArticleRef(board, match[2]);
74
+ }
75
+ function defineProvider(provider) {
76
+ return Object.freeze(provider);
77
+ }
78
+ const officialProvider = defineProvider({
79
+ hosts: ["www.ptt.cc", "ptt.cc"],
80
+ id: "official",
81
+ label: "PTT 官方",
82
+ parseUrl: (url) => parsePttStylePath(url.pathname),
83
+ toUrl: (article) => `https://www.ptt.cc/bbs/${encodeURIComponent(article.board)}/${article.articleId}.html`
84
+ });
85
+ const bepttProvider = defineProvider({
86
+ hosts: ["bbs.beptt.tw", "bbs.beptt.cc"],
87
+ id: "beptt",
88
+ label: "BePTT",
89
+ parseUrl: (url) => {
90
+ const match = /^\/([^/]+)\/((?:M|G)\.\d+\.A(?:\.[0-9A-Fa-f]{3})?)\/?$/.exec(url.pathname);
91
+ if (!match || match[1] === void 0 || match[2] === void 0) return null;
92
+ const board = decodePathPart(match[1]);
93
+ return board === null ? null : normalizeArticleRef(board, match[2]);
94
+ },
95
+ toUrl: (article) => `https://bbs.beptt.tw/${encodeURIComponent(article.board)}/${article.articleId}`
96
+ });
97
+ const mopttProvider = defineProvider({
98
+ hosts: ["moptt.tw", "www.moptt.tw"],
99
+ id: "moptt",
100
+ label: "MoPTT",
101
+ parseUrl: (url) => {
102
+ const match = /^\/p\/(.+?)\.((?:M|G)\.\d+\.A(?:\.[0-9A-Fa-f]{3})?)\/?$/.exec(url.pathname);
103
+ if (!match || match[1] === void 0 || match[2] === void 0) return null;
104
+ const board = decodePathPart(match[1]);
105
+ return board === null ? null : normalizeArticleRef(board, match[2]);
106
+ },
107
+ toUrl: (article) => `https://moptt.tw/p/${encodeURIComponent(article.board)}.${article.articleId}`
108
+ });
109
+ const pttwebProvider = defineProvider({
110
+ hosts: ["www.pttweb.cc", "pttweb.cc"],
111
+ id: "pttweb",
112
+ label: "PTTweb",
113
+ parseUrl: (url) => parsePttStylePath(url.pathname),
114
+ toUrl: (article) => `https://www.pttweb.cc/bbs/${encodeURIComponent(article.board)}/${article.articleId}`
115
+ });
116
+ const providers = Object.freeze([
117
+ officialProvider,
118
+ bepttProvider,
119
+ mopttProvider,
120
+ pttwebProvider
121
+ ]);
122
+ function getProvider(adapters, id) {
123
+ return adapters.find((adapter) => adapter.id === id);
124
+ }
125
+ function generateProviderLinks(article, adapters = providers) {
126
+ return adapters.map((adapter) => ({
127
+ id: adapter.id,
128
+ label: adapter.label,
129
+ url: adapter.toUrl(article)
130
+ }));
131
+ }
132
+ //#endregion
133
+ //#region src/parse.ts
134
+ const URL_PATTERN = /https?:\/\/[^\s<>"']+/giu;
135
+ const AID_INPUT_PATTERN = /^#?([0-9A-Za-z_-]{8})(?:\s*\(([^)]+)\))?$/;
136
+ const TRAILING_PUNCTUATION = /[),.!?,。!?;;::]+$/u;
137
+ function urlsFromText(input) {
138
+ const matches = input.match(URL_PATTERN) ?? [];
139
+ const urls = [];
140
+ for (const match of matches) try {
141
+ urls.push(new URL(match.replace(TRAILING_PUNCTUATION, "")));
142
+ } catch {}
143
+ return urls;
144
+ }
145
+ function adapterForUrl(url, adapters) {
146
+ const hostname = url.hostname.toLowerCase();
147
+ return adapters.find((adapter) => adapter.hosts.includes(hostname));
148
+ }
149
+ function success(article, source) {
150
+ return {
151
+ article,
152
+ ok: true,
153
+ source
154
+ };
155
+ }
156
+ function parsePttInput(input, options = {}) {
157
+ const trimmed = input.trim();
158
+ if (trimmed.length === 0) return {
159
+ error: {
160
+ code: "empty_input",
161
+ message: "Enter a PTT article URL or full AID."
162
+ },
163
+ ok: false
164
+ };
165
+ const adapters = options.providers ?? providers;
166
+ const urls = urlsFromText(trimmed);
167
+ let unsupportedHost;
168
+ for (const url of urls) {
169
+ const adapter = adapterForUrl(url, adapters);
170
+ if (adapter === void 0) {
171
+ unsupportedHost ??= url.hostname;
172
+ continue;
173
+ }
174
+ const article = adapter.parseUrl(url);
175
+ if (article !== null) return success(article, {
176
+ kind: "url",
177
+ providerId: adapter.id,
178
+ url: url.toString()
179
+ });
180
+ }
181
+ const aidMatch = AID_INPUT_PATTERN.exec(trimmed);
182
+ if (aidMatch?.[1] !== void 0) {
183
+ const aid = aidMatch[1];
184
+ if (!isValidAid(aid)) return {
185
+ error: {
186
+ code: "invalid_aid",
187
+ message: "The AID must contain exactly 8 characters."
188
+ },
189
+ ok: false
190
+ };
191
+ const board = (aidMatch[2] ?? options.board)?.trim();
192
+ if (board === void 0 || board.length === 0) return {
193
+ error: {
194
+ aid: `#${aid}`,
195
+ code: "missing_board",
196
+ message: "Add the board name to convert this AID offline."
197
+ },
198
+ ok: false
199
+ };
200
+ if (!isValidBoard(board)) return {
201
+ error: {
202
+ code: "invalid_aid",
203
+ message: "The board name is not valid."
204
+ },
205
+ ok: false
206
+ };
207
+ const articleId = aidToArticleId(aid);
208
+ const article = articleId === null ? null : normalizeArticleRef(board, articleId);
209
+ if (article !== null) return success(article, { kind: "aid" });
210
+ }
211
+ if (trimmed.startsWith("#")) return {
212
+ error: {
213
+ code: "invalid_aid",
214
+ message: "The AID must contain exactly 8 supported characters."
215
+ },
216
+ ok: false
217
+ };
218
+ if (unsupportedHost !== void 0) return {
219
+ error: {
220
+ code: "unsupported_provider",
221
+ host: unsupportedHost,
222
+ message: `Unsupported PTT reader: ${unsupportedHost}`
223
+ },
224
+ ok: false
225
+ };
226
+ return {
227
+ error: {
228
+ code: "unrecognized_input",
229
+ message: "No supported PTT article URL or full AID was found."
230
+ },
231
+ ok: false
232
+ };
233
+ }
234
+ function tryParsePttInput(input, options) {
235
+ const result = parsePttInput(input, options);
236
+ return result.ok ? result.article : null;
237
+ }
238
+ //#endregion
239
+ export { aidToArticleId, articleIdToAid, bepttProvider, generateProviderLinks, getProvider, isValidAid, isValidBoard, mopttProvider, normalizeArticleRef, officialProvider, parsePttInput, providers, pttwebProvider, tryParsePttInput };
240
+
241
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["ARTICLE_ID_PATTERN","defaultProviders"],"sources":["../src/aid.ts","../src/validation.ts","../src/providers.ts","../src/parse.ts"],"sourcesContent":["const AID_ALPHABET = \"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_\";\nconst AID_PATTERN = /^[0-9A-Za-z_-]{8}$/;\nconst ARTICLE_ID_PATTERN = /^(M|G)\\.(\\d+)\\.A(?:\\.([0-9A-F]{3}))?$/;\n\nexport function isValidAid(value: string): boolean {\n return AID_PATTERN.test(value);\n}\n\nexport function aidToArticleId(aid: string): string | null {\n const normalized = aid.startsWith(\"#\") ? aid.slice(1) : aid;\n if (!isValidAid(normalized)) return null;\n\n let timestamp = 0;\n for (const character of normalized.slice(0, 6)) {\n const index = AID_ALPHABET.indexOf(character);\n if (index < 0) return null;\n timestamp = timestamp * 64 + index;\n }\n\n let checksum = 0;\n for (const character of normalized.slice(6)) {\n const index = AID_ALPHABET.indexOf(character);\n if (index < 0) return null;\n checksum = checksum * 64 + index;\n }\n\n const checksumPart =\n checksum === 0 ? \"\" : `.${checksum.toString(16).toUpperCase().padStart(3, \"0\")}`;\n return `M.${timestamp}.A${checksumPart}`;\n}\n\nexport function articleIdToAid(articleId: string): string | null {\n const match = ARTICLE_ID_PATTERN.exec(articleId.toUpperCase());\n if (!match || match[1] !== \"M\" || match[2] === undefined) return null;\n\n let timestamp = Number(match[2]);\n let checksum = match[3] === undefined ? 0 : Number.parseInt(match[3], 16);\n if (!Number.isSafeInteger(timestamp) || timestamp < 0 || checksum > 0xfff) return null;\n\n let encodedTimestamp = \"\";\n for (let index = 0; index < 6; index += 1) {\n encodedTimestamp = AID_ALPHABET[timestamp % 64]! + encodedTimestamp;\n timestamp = Math.floor(timestamp / 64);\n }\n\n let encodedChecksum = \"\";\n for (let index = 0; index < 2; index += 1) {\n encodedChecksum = AID_ALPHABET[checksum % 64]! + encodedChecksum;\n checksum = Math.floor(checksum / 64);\n }\n\n return `#${encodedTimestamp}${encodedChecksum}`;\n}\n","import type { PttArticleRef } from \"./types.js\";\n\nconst BOARD_PATTERN = /^[A-Za-z0-9_.-]+$/;\nconst ARTICLE_ID_PATTERN = /^(M|G)\\.\\d+\\.A(?:\\.[0-9A-F]{3})?$/;\n\nexport function normalizeArticleRef(board: string, articleId: string): PttArticleRef | null {\n const normalizedBoard = board.trim();\n const normalizedArticleId = articleId.trim().toUpperCase();\n\n if (\n normalizedBoard.length === 0 ||\n normalizedBoard.length > 64 ||\n !BOARD_PATTERN.test(normalizedBoard) ||\n !ARTICLE_ID_PATTERN.test(normalizedArticleId)\n ) {\n return null;\n }\n\n return { articleId: normalizedArticleId, board: normalizedBoard };\n}\n\nexport function isValidBoard(board: string): boolean {\n return normalizeArticleRef(board, \"M.0.A\") !== null;\n}\n","import type { ProviderAdapter, ProviderId, ProviderLink, PttArticleRef, PttUrl } from \"./types.js\";\nimport { normalizeArticleRef } from \"./validation.js\";\n\nfunction decodePathPart(value: string): string | null {\n try {\n return decodeURIComponent(value);\n } catch {\n return null;\n }\n}\n\nfunction parsePttStylePath(pathname: string): PttArticleRef | null {\n const match = /^\\/bbs\\/([^/]+)\\/((?:M|G)\\.\\d+\\.A(?:\\.[0-9A-Fa-f]{3})?)(?:\\.html)?\\/?$/.exec(\n pathname,\n );\n if (!match || match[1] === undefined || match[2] === undefined) return null;\n const board = decodePathPart(match[1]);\n return board === null ? null : normalizeArticleRef(board, match[2]);\n}\n\nfunction defineProvider<TId extends ProviderId>(\n provider: ProviderAdapter<TId>,\n): ProviderAdapter<TId> {\n return Object.freeze(provider);\n}\n\nexport const officialProvider: ProviderAdapter<\"official\"> = defineProvider({\n hosts: [\"www.ptt.cc\", \"ptt.cc\"],\n id: \"official\",\n label: \"PTT 官方\",\n parseUrl: (url: PttUrl): PttArticleRef | null => parsePttStylePath(url.pathname),\n toUrl: (article: PttArticleRef): string =>\n `https://www.ptt.cc/bbs/${encodeURIComponent(article.board)}/${article.articleId}.html`,\n});\n\nexport const bepttProvider: ProviderAdapter<\"beptt\"> = defineProvider({\n hosts: [\"bbs.beptt.tw\", \"bbs.beptt.cc\"],\n id: \"beptt\",\n label: \"BePTT\",\n parseUrl: (url: PttUrl): PttArticleRef | null => {\n const match = /^\\/([^/]+)\\/((?:M|G)\\.\\d+\\.A(?:\\.[0-9A-Fa-f]{3})?)\\/?$/.exec(url.pathname);\n if (!match || match[1] === undefined || match[2] === undefined) return null;\n const board = decodePathPart(match[1]);\n return board === null ? null : normalizeArticleRef(board, match[2]);\n },\n toUrl: (article: PttArticleRef): string =>\n `https://bbs.beptt.tw/${encodeURIComponent(article.board)}/${article.articleId}`,\n});\n\nexport const mopttProvider: ProviderAdapter<\"moptt\"> = defineProvider({\n hosts: [\"moptt.tw\", \"www.moptt.tw\"],\n id: \"moptt\",\n label: \"MoPTT\",\n parseUrl: (url: PttUrl): PttArticleRef | null => {\n const match = /^\\/p\\/(.+?)\\.((?:M|G)\\.\\d+\\.A(?:\\.[0-9A-Fa-f]{3})?)\\/?$/.exec(url.pathname);\n if (!match || match[1] === undefined || match[2] === undefined) return null;\n const board = decodePathPart(match[1]);\n return board === null ? null : normalizeArticleRef(board, match[2]);\n },\n toUrl: (article: PttArticleRef): string =>\n `https://moptt.tw/p/${encodeURIComponent(article.board)}.${article.articleId}`,\n});\n\nexport const pttwebProvider: ProviderAdapter<\"pttweb\"> = defineProvider({\n hosts: [\"www.pttweb.cc\", \"pttweb.cc\"],\n id: \"pttweb\",\n label: \"PTTweb\",\n parseUrl: (url: PttUrl): PttArticleRef | null => parsePttStylePath(url.pathname),\n toUrl: (article: PttArticleRef): string =>\n `https://www.pttweb.cc/bbs/${encodeURIComponent(article.board)}/${article.articleId}`,\n});\n\nexport const providers: readonly [\n ProviderAdapter<\"official\">,\n ProviderAdapter<\"beptt\">,\n ProviderAdapter<\"moptt\">,\n ProviderAdapter<\"pttweb\">,\n] = Object.freeze([officialProvider, bepttProvider, mopttProvider, pttwebProvider] as const);\n\nexport function getProvider<TAdapter extends ProviderAdapter>(\n adapters: readonly TAdapter[],\n id: string,\n): TAdapter | undefined {\n return adapters.find((adapter) => adapter.id === id);\n}\n\nexport function generateProviderLinks(article: PttArticleRef): ProviderLink<ProviderId>[];\nexport function generateProviderLinks<TAdapter extends ProviderAdapter>(\n article: PttArticleRef,\n adapters: readonly TAdapter[],\n): ProviderLink<TAdapter[\"id\"]>[];\nexport function generateProviderLinks(\n article: PttArticleRef,\n adapters: readonly ProviderAdapter[] = providers,\n): ProviderLink[] {\n return adapters.map((adapter) => ({\n id: adapter.id,\n label: adapter.label,\n url: adapter.toUrl(article),\n }));\n}\n","import { aidToArticleId, isValidAid } from \"./aid.js\";\nimport { providers as defaultProviders } from \"./providers.js\";\nimport type { ParseOptions, ParseResult, ProviderAdapter, PttArticleRef } from \"./types.js\";\nimport { isValidBoard, normalizeArticleRef } from \"./validation.js\";\n\nconst URL_PATTERN = /https?:\\/\\/[^\\s<>\"']+/giu;\nconst AID_INPUT_PATTERN = /^#?([0-9A-Za-z_-]{8})(?:\\s*\\(([^)]+)\\))?$/;\nconst TRAILING_PUNCTUATION = /[),.!?,。!?;;::]+$/u;\n\nfunction urlsFromText(input: string): URL[] {\n const matches = input.match(URL_PATTERN) ?? [];\n const urls: URL[] = [];\n\n for (const match of matches) {\n try {\n urls.push(new URL(match.replace(TRAILING_PUNCTUATION, \"\")));\n } catch {\n // Ignore malformed URL-like fragments and continue to AID parsing.\n }\n }\n\n return urls;\n}\n\nfunction adapterForUrl(\n url: URL,\n adapters: readonly ProviderAdapter[],\n): ProviderAdapter | undefined {\n const hostname = url.hostname.toLowerCase();\n return adapters.find((adapter) => adapter.hosts.includes(hostname));\n}\n\nfunction success(\n article: PttArticleRef,\n source:\n | { readonly kind: \"aid\" }\n | {\n readonly kind: \"url\";\n readonly providerId: string;\n readonly url: string;\n },\n): ParseResult {\n return { article, ok: true, source };\n}\n\nexport function parsePttInput(input: string, options: ParseOptions = {}): ParseResult {\n const trimmed = input.trim();\n if (trimmed.length === 0) {\n return {\n error: { code: \"empty_input\", message: \"Enter a PTT article URL or full AID.\" },\n ok: false,\n };\n }\n\n const adapters = options.providers ?? defaultProviders;\n const urls = urlsFromText(trimmed);\n let unsupportedHost: string | undefined;\n\n for (const url of urls) {\n const adapter = adapterForUrl(url, adapters);\n if (adapter === undefined) {\n unsupportedHost ??= url.hostname;\n continue;\n }\n\n const article = adapter.parseUrl(url);\n if (article !== null) {\n return success(article, {\n kind: \"url\",\n providerId: adapter.id,\n url: url.toString(),\n });\n }\n }\n\n const aidMatch = AID_INPUT_PATTERN.exec(trimmed);\n if (aidMatch?.[1] !== undefined) {\n const aid = aidMatch[1];\n if (!isValidAid(aid)) {\n return {\n error: { code: \"invalid_aid\", message: \"The AID must contain exactly 8 characters.\" },\n ok: false,\n };\n }\n\n const board = (aidMatch[2] ?? options.board)?.trim();\n if (board === undefined || board.length === 0) {\n return {\n error: {\n aid: `#${aid}`,\n code: \"missing_board\",\n message: \"Add the board name to convert this AID offline.\",\n },\n ok: false,\n };\n }\n\n if (!isValidBoard(board)) {\n return {\n error: { code: \"invalid_aid\", message: \"The board name is not valid.\" },\n ok: false,\n };\n }\n\n const articleId = aidToArticleId(aid);\n const article = articleId === null ? null : normalizeArticleRef(board, articleId);\n if (article !== null) return success(article, { kind: \"aid\" });\n }\n\n if (trimmed.startsWith(\"#\")) {\n return {\n error: {\n code: \"invalid_aid\",\n message: \"The AID must contain exactly 8 supported characters.\",\n },\n ok: false,\n };\n }\n\n if (unsupportedHost !== undefined) {\n return {\n error: {\n code: \"unsupported_provider\",\n host: unsupportedHost,\n message: `Unsupported PTT reader: ${unsupportedHost}`,\n },\n ok: false,\n };\n }\n\n return {\n error: {\n code: \"unrecognized_input\",\n message: \"No supported PTT article URL or full AID was found.\",\n },\n ok: false,\n };\n}\n\nexport function tryParsePttInput(input: string, options?: ParseOptions): PttArticleRef | null {\n const result = parsePttInput(input, options);\n return result.ok ? result.article : null;\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@vp-tw/ptt-link-switcher",
3
+ "version": "0.1.0",
4
+ "description": "Parse and convert PTT article links across supported readers—entirely offline.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/vp-tw/ptt-link-switcher.git",
9
+ "directory": "packages/core"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "scripts": {
28
+ "build": "tsdown",
29
+ "test": "vp test --run",
30
+ "typecheck": "tsgo --build --pretty"
31
+ }
32
+ }