@symbiosis-lab/moss-plugin-matters 1.4.2
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 +88 -0
- package/README.md +18 -0
- package/assets/icon.svg +1 -0
- package/assets/manifest.json +36 -0
- package/codegen.ts +26 -0
- package/e2e/moss-cli.test.ts +338 -0
- package/features/api/fetch-articles.feature +39 -0
- package/features/auth/wallet-auth.feature +27 -0
- package/features/download/retry-logic.feature +36 -0
- package/features/download/self-correcting.feature +83 -0
- package/features/download/worker-pool.feature +29 -0
- package/features/social/fetch-social-data.feature +40 -0
- package/features/steps/api.steps.ts +180 -0
- package/features/steps/download.steps.ts +423 -0
- package/features/steps/incremental-sync.steps.ts +105 -0
- package/features/steps/self-correcting.steps.ts +575 -0
- package/features/steps/social.steps.ts +257 -0
- package/features/steps/syndication.steps.ts +264 -0
- package/features/steps/wallet-auth.steps.ts +185 -0
- package/features/sync/article-sync.feature +49 -0
- package/features/sync/homepage-grid.feature +43 -0
- package/features/sync/incremental-sync.feature +28 -0
- package/features/syndication/create-draft.feature +35 -0
- package/package.json +58 -0
- package/src/__generated__/schema.graphql +4289 -0
- package/src/__generated__/types.ts +5355 -0
- package/src/__tests__/api.test.ts +678 -0
- package/src/__tests__/auth-route.test.ts +38 -0
- package/src/__tests__/auth-routing.test.ts +462 -0
- package/src/__tests__/auto-detect.test.ts +412 -0
- package/src/__tests__/binding-guard.test.ts +256 -0
- package/src/__tests__/config.test.ts +212 -0
- package/src/__tests__/converter.test.ts +289 -0
- package/src/__tests__/credential.test.ts +332 -0
- package/src/__tests__/domain.test.ts +341 -0
- package/src/__tests__/downloader.test.ts +679 -0
- package/src/__tests__/folder-detection.test.ts +289 -0
- package/src/__tests__/force-fresh-login.test.ts +236 -0
- package/src/__tests__/main.test.ts +2437 -0
- package/src/__tests__/progress.test.ts +93 -0
- package/src/__tests__/session.test.ts +375 -0
- package/src/__tests__/social-integration.test.ts +386 -0
- package/src/__tests__/social-sync-logic.test.ts +107 -0
- package/src/__tests__/social.test.ts +788 -0
- package/src/__tests__/sync.test.ts +1273 -0
- package/src/__tests__/syndication-toast-law.test.ts +649 -0
- package/src/__tests__/syndication.test.ts +125 -0
- package/src/__tests__/test-profile-escape.test.ts +209 -0
- package/src/__tests__/url-detect.test.ts +79 -0
- package/src/__tests__/utils.test.ts +226 -0
- package/src/api.ts +1366 -0
- package/src/auth-route.ts +38 -0
- package/src/config.ts +80 -0
- package/src/converter.ts +305 -0
- package/src/credential.ts +329 -0
- package/src/domain.ts +183 -0
- package/src/downloader.ts +761 -0
- package/src/main.ts +2092 -0
- package/src/progress.ts +89 -0
- package/src/queries/user.graphql +85 -0
- package/src/queries/viewer.graphql +104 -0
- package/src/social.ts +413 -0
- package/src/sync.ts +818 -0
- package/src/types.ts +477 -0
- package/src/url-detect.ts +49 -0
- package/src/utils.ts +305 -0
- package/test-fixtures/syndication-test-site/input/index.md +8 -0
- package/test-fixtures/syndication-test-site/input/posts/rich-test-article.md +90 -0
- package/test-helpers/TEST_ACCOUNT.md +151 -0
- package/test-helpers/api-client.ts +252 -0
- package/test-helpers/fixtures/articles.ts +147 -0
- package/test-helpers/wallet-auth.ts +305 -0
- package/test-setup/e2e.ts +93 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +39 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure auth-routing decision for the process hook.
|
|
3
|
+
*
|
|
4
|
+
* Inputs: the tri-state session check (api.ts getSessionState), moss's
|
|
5
|
+
* trigger stamp (ADR-015, snake_case; absent ⇒ "background"), and whether a
|
|
6
|
+
* userName is saved in config (enables public-mode import).
|
|
7
|
+
*
|
|
8
|
+
* Invariant: background NEVER opens a login window. A quiet rebuild popping
|
|
9
|
+
* a browser uninvited is the bug class this module exists to kill.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SessionState } from "./credential";
|
|
13
|
+
|
|
14
|
+
export type AuthRoute = "proceed" | "prompt_login" | "public_fallback" | "soft_fail";
|
|
15
|
+
|
|
16
|
+
const USER_PRESENT_TRIGGERS = new Set(["onboarding_flow", "settings_manual", "manual_one"]);
|
|
17
|
+
|
|
18
|
+
/** Unknown or absent triggers count as background (quiet default). */
|
|
19
|
+
export function isUserPresent(trigger: string | undefined): boolean {
|
|
20
|
+
return trigger !== undefined && USER_PRESENT_TRIGGERS.has(trigger);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolveAuthRoute(
|
|
24
|
+
state: SessionState,
|
|
25
|
+
trigger: string | undefined,
|
|
26
|
+
hasUserName: boolean
|
|
27
|
+
): AuthRoute {
|
|
28
|
+
if (state === "valid") return "proceed";
|
|
29
|
+
|
|
30
|
+
if (isUserPresent(trigger)) {
|
|
31
|
+
// Expired session + present user: re-login beats a degraded import.
|
|
32
|
+
if (state === "expired") return "prompt_login";
|
|
33
|
+
// Never logged in: keep today's behavior (public fallback when bound).
|
|
34
|
+
return hasUserName ? "public_fallback" : "prompt_login";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return hasUserName ? "public_fallback" : "soft_fail";
|
|
38
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration management
|
|
3
|
+
*
|
|
4
|
+
* Uses the moss-api plugin storage API to automatically store config
|
|
5
|
+
* in the plugin's private directory (.moss/plugins/{plugin-name}/).
|
|
6
|
+
* No need to know the path - just call readPluginFile("config.json").
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
readPluginFile,
|
|
11
|
+
writePluginFile,
|
|
12
|
+
pluginFileExists,
|
|
13
|
+
} from "@symbiosis-lab/moss-api";
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Plugin configuration stored in config.json
|
|
21
|
+
*/
|
|
22
|
+
export interface MattersPluginConfig {
|
|
23
|
+
/** Matters username this project is bound to (guards auto-sync) */
|
|
24
|
+
boundUserName?: string;
|
|
25
|
+
/** Matters.town username (allows unauthenticated mode when cookie unavailable) */
|
|
26
|
+
userName?: string;
|
|
27
|
+
/** User's language preference (e.g., "zh_hans", "zh_hant", "en") */
|
|
28
|
+
language?: string;
|
|
29
|
+
/** ISO timestamp of last successful sync completion (for incremental sync) */
|
|
30
|
+
lastSyncedAt?: string;
|
|
31
|
+
/** Whether to sync drafts (default: false) */
|
|
32
|
+
sync_drafts?: boolean;
|
|
33
|
+
/** Explicit article folder name override (auto-detected if not set) */
|
|
34
|
+
articleFolder?: string;
|
|
35
|
+
/** Override Matters domain (default: "matters.town", test: "matters.icu") */
|
|
36
|
+
domain?: string;
|
|
37
|
+
/** Known collection IDs from previous syncs (collections lack createdAt, so we track IDs to detect new ones) */
|
|
38
|
+
knownCollectionIds?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Functions
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read plugin configuration from storage
|
|
47
|
+
*
|
|
48
|
+
* Config is automatically stored in the plugin's private directory.
|
|
49
|
+
* Must be called from within a plugin hook.
|
|
50
|
+
*
|
|
51
|
+
* @returns Plugin configuration object (empty object if not found or invalid)
|
|
52
|
+
*/
|
|
53
|
+
export async function getConfig(): Promise<MattersPluginConfig> {
|
|
54
|
+
try {
|
|
55
|
+
const exists = await pluginFileExists("config.json");
|
|
56
|
+
if (!exists) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const content = await readPluginFile("config.json");
|
|
61
|
+
return JSON.parse(content) as MattersPluginConfig;
|
|
62
|
+
} catch {
|
|
63
|
+
// Return empty config on any error (file not found, parse error, etc.)
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Save plugin configuration to storage
|
|
70
|
+
*
|
|
71
|
+
* Config is automatically stored in the plugin's private directory.
|
|
72
|
+
* Must be called from within a plugin hook.
|
|
73
|
+
*
|
|
74
|
+
* @param config - Configuration object to save
|
|
75
|
+
* @throws Error if write fails
|
|
76
|
+
*/
|
|
77
|
+
export async function saveConfig(config: MattersPluginConfig): Promise<void> {
|
|
78
|
+
const content = JSON.stringify(config, null, 2);
|
|
79
|
+
await writePluginFile("config.json", content);
|
|
80
|
+
}
|
package/src/converter.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content conversion utilities: frontmatter handling + image/link extraction.
|
|
3
|
+
*
|
|
4
|
+
* NOTE: HTML→Markdown is NOT done here. Production converts via moss's shared
|
|
5
|
+
* Rust `htmd` converter, imported as `htmlToMarkdown` from `@symbiosis-lab/moss-api`
|
|
6
|
+
* (see `sync.ts`). The hand-rolled DOM-walking converter that used to live here
|
|
7
|
+
* was deleted (B4) — it duplicated functionality moss already owns and shipped
|
|
8
|
+
* the lone-backslash `<br>` bug (B3).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { FrontmatterData, ParsedFrontmatter } from "./types";
|
|
12
|
+
|
|
13
|
+
// ============================================================================
|
|
14
|
+
// Frontmatter Handling
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Escape string for YAML (escape backslashes first, then quotes)
|
|
19
|
+
*/
|
|
20
|
+
function escapeYaml(str: string): string {
|
|
21
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate frontmatter YAML from data object
|
|
26
|
+
*/
|
|
27
|
+
export function generateFrontmatter(data: FrontmatterData): string {
|
|
28
|
+
const lines: string[] = ["---"];
|
|
29
|
+
|
|
30
|
+
lines.push(`title: "${escapeYaml(data.title)}"`);
|
|
31
|
+
|
|
32
|
+
if (data.home) {
|
|
33
|
+
lines.push("home: true");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (data.description) {
|
|
37
|
+
lines.push(`description: "${escapeYaml(data.description)}"`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (data.date) {
|
|
41
|
+
lines.push(`date: "${data.date}"`);
|
|
42
|
+
}
|
|
43
|
+
if (data.updated) {
|
|
44
|
+
lines.push(`updated: "${data.updated}"`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (data.tags && data.tags.length > 0) {
|
|
48
|
+
lines.push("tags:");
|
|
49
|
+
for (const tag of data.tags) {
|
|
50
|
+
lines.push(` - "${escapeYaml(tag)}"`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (data.cover) {
|
|
55
|
+
lines.push(`cover: "${data.cover}"`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (data.syndicated && data.syndicated.length > 0) {
|
|
59
|
+
lines.push("syndicated:");
|
|
60
|
+
for (const url of data.syndicated) {
|
|
61
|
+
lines.push(` - "${url}"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (data.collections) {
|
|
66
|
+
if (Array.isArray(data.collections)) {
|
|
67
|
+
// Array format: collections as list of slugs
|
|
68
|
+
if (data.collections.length > 0) {
|
|
69
|
+
lines.push("collections:");
|
|
70
|
+
for (const slug of data.collections) {
|
|
71
|
+
lines.push(` - "${slug}"`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} else if (Object.keys(data.collections).length > 0) {
|
|
75
|
+
// Object format: collections with order numbers
|
|
76
|
+
lines.push("collections:");
|
|
77
|
+
for (const [slug, order] of Object.entries(data.collections)) {
|
|
78
|
+
lines.push(` ${slug}: ${order}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (data.order && data.order.length > 0) {
|
|
84
|
+
lines.push("order:");
|
|
85
|
+
for (const filename of data.order) {
|
|
86
|
+
lines.push(` - "${filename}"`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines.push("---");
|
|
91
|
+
|
|
92
|
+
return lines.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse frontmatter from markdown content
|
|
97
|
+
*/
|
|
98
|
+
export function parseFrontmatter(content: string): ParsedFrontmatter | null {
|
|
99
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
100
|
+
if (!match) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const frontmatterStr = match[1];
|
|
105
|
+
const body = match[2];
|
|
106
|
+
|
|
107
|
+
const frontmatter: Record<string, unknown> = {};
|
|
108
|
+
const lines = frontmatterStr.split("\n");
|
|
109
|
+
let currentKey = "";
|
|
110
|
+
let currentArray: string[] = [];
|
|
111
|
+
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (line.startsWith(" - ")) {
|
|
114
|
+
const value = line.substring(4).replace(/^"(.*)"$/, "$1");
|
|
115
|
+
currentArray.push(value);
|
|
116
|
+
} else if (line.includes(":")) {
|
|
117
|
+
// Save previous array if any
|
|
118
|
+
if (currentKey && currentArray.length > 0) {
|
|
119
|
+
frontmatter[currentKey] = currentArray;
|
|
120
|
+
currentArray = [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const colonIndex = line.indexOf(":");
|
|
124
|
+
const key = line.substring(0, colonIndex);
|
|
125
|
+
const rest = line.substring(colonIndex + 1).trim();
|
|
126
|
+
|
|
127
|
+
if (rest === "") {
|
|
128
|
+
// Array or object key (e.g., "tags:")
|
|
129
|
+
currentKey = key;
|
|
130
|
+
currentArray = [];
|
|
131
|
+
} else {
|
|
132
|
+
// Simple key-value pair
|
|
133
|
+
currentKey = "";
|
|
134
|
+
frontmatter[key] = rest.replace(/^"(.*)"$/, "$1");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Save last array if any
|
|
140
|
+
if (currentKey && currentArray.length > 0) {
|
|
141
|
+
frontmatter[currentKey] = currentArray;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { frontmatter, body };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Regenerate frontmatter YAML from parsed object
|
|
149
|
+
*/
|
|
150
|
+
export function regenerateFrontmatter(frontmatter: Record<string, unknown>): string {
|
|
151
|
+
const lines: string[] = ["---"];
|
|
152
|
+
|
|
153
|
+
const formatValue = (value: unknown): string => {
|
|
154
|
+
if (typeof value === "string") {
|
|
155
|
+
if (value.includes(":") || value.includes("#") || value.includes('"') || value.startsWith(" ")) {
|
|
156
|
+
return `"${escapeYaml(value)}"`;
|
|
157
|
+
}
|
|
158
|
+
return `"${value}"`;
|
|
159
|
+
}
|
|
160
|
+
return String(value);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const fieldOrder = ["title", "description", "date", "updated", "tags", "cover", "syndicated", "collections", "order"];
|
|
164
|
+
|
|
165
|
+
for (const key of fieldOrder) {
|
|
166
|
+
if (!(key in frontmatter)) continue;
|
|
167
|
+
const value = frontmatter[key];
|
|
168
|
+
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
lines.push(`${key}:`);
|
|
171
|
+
for (const item of value) {
|
|
172
|
+
lines.push(` - ${formatValue(item)}`);
|
|
173
|
+
}
|
|
174
|
+
} else if (typeof value === "object" && value !== null) {
|
|
175
|
+
lines.push(`${key}:`);
|
|
176
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
177
|
+
lines.push(` ${subKey}: ${subValue}`);
|
|
178
|
+
}
|
|
179
|
+
} else if (typeof value === "boolean") {
|
|
180
|
+
lines.push(`${key}: ${value}`);
|
|
181
|
+
} else {
|
|
182
|
+
lines.push(`${key}: ${formatValue(value)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
187
|
+
if (fieldOrder.includes(key)) continue;
|
|
188
|
+
|
|
189
|
+
if (Array.isArray(value)) {
|
|
190
|
+
lines.push(`${key}:`);
|
|
191
|
+
for (const item of value) {
|
|
192
|
+
lines.push(` - ${formatValue(item)}`);
|
|
193
|
+
}
|
|
194
|
+
} else if (typeof value === "object" && value !== null) {
|
|
195
|
+
lines.push(`${key}:`);
|
|
196
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
197
|
+
lines.push(` ${subKey}: ${subValue}`);
|
|
198
|
+
}
|
|
199
|
+
} else if (typeof value === "boolean") {
|
|
200
|
+
lines.push(`${key}: ${value}`);
|
|
201
|
+
} else {
|
|
202
|
+
lines.push(`${key}: ${formatValue(value)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
lines.push("---");
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Extract all markdown links from content (not images)
|
|
212
|
+
* Used for detecting internal Matters links that need rewriting
|
|
213
|
+
*/
|
|
214
|
+
export function extractMarkdownLinks(content: string): Array<{
|
|
215
|
+
url: string;
|
|
216
|
+
fullMatch: string;
|
|
217
|
+
}> {
|
|
218
|
+
const results: Array<{ url: string; fullMatch: string }> = [];
|
|
219
|
+
// Match markdown links [text](url) but NOT images 
|
|
220
|
+
// Negative lookbehind (?<!!") ensures we don't match image syntax
|
|
221
|
+
const linkPattern = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
|
|
222
|
+
let match;
|
|
223
|
+
|
|
224
|
+
while ((match = linkPattern.exec(content)) !== null) {
|
|
225
|
+
results.push({
|
|
226
|
+
fullMatch: match[0],
|
|
227
|
+
url: match[2].trim(),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return results;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Extract remote image URLs from markdown content
|
|
236
|
+
*/
|
|
237
|
+
export function extractRemoteImageUrls(
|
|
238
|
+
content: string
|
|
239
|
+
): Array<{ url: string; localFilename: string }> {
|
|
240
|
+
const results: Array<{ url: string; localFilename: string }> = [];
|
|
241
|
+
const seen = new Set<string>();
|
|
242
|
+
|
|
243
|
+
const imagePattern = /!\[[^\]]*\]\(([^)]+)\)/g;
|
|
244
|
+
let match;
|
|
245
|
+
|
|
246
|
+
while ((match = imagePattern.exec(content)) !== null) {
|
|
247
|
+
const url = match[1].trim();
|
|
248
|
+
|
|
249
|
+
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (seen.has(url)) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
seen.add(url);
|
|
257
|
+
|
|
258
|
+
const localFilename = generateLocalFilenameFromUrl(url);
|
|
259
|
+
if (localFilename) {
|
|
260
|
+
results.push({ url, localFilename });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return results;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Generate local filename from URL (duplicated here to avoid circular dependency)
|
|
269
|
+
*/
|
|
270
|
+
function generateLocalFilenameFromUrl(url: string): string | null {
|
|
271
|
+
try {
|
|
272
|
+
const urlObj = new URL(url);
|
|
273
|
+
const pathname = urlObj.pathname;
|
|
274
|
+
const cleanPath = pathname.replace(/\/public$/, "");
|
|
275
|
+
const segments = cleanPath.split("/").filter((s) => s.length > 0);
|
|
276
|
+
|
|
277
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
278
|
+
const segment = segments[i];
|
|
279
|
+
const extMatch = segment.match(/\.(\w+)$/);
|
|
280
|
+
if (extMatch) {
|
|
281
|
+
const ext = extMatch[1].toLowerCase();
|
|
282
|
+
if (i > 0 && /^[a-f0-9-]{36}$/i.test(segments[i - 1])) {
|
|
283
|
+
return `${segments[i - 1]}.${ext}`;
|
|
284
|
+
}
|
|
285
|
+
return segment;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const segment of segments) {
|
|
290
|
+
if (/^[a-f0-9-]{36}$/i.test(segment)) {
|
|
291
|
+
return segment;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Simple hash fallback
|
|
296
|
+
let hash = 0;
|
|
297
|
+
for (let i = 0; i < url.length; i++) {
|
|
298
|
+
hash = ((hash << 5) - hash) + url.charCodeAt(i);
|
|
299
|
+
hash = hash & hash;
|
|
300
|
+
}
|
|
301
|
+
return Math.abs(hash).toString(16);
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|