Files
2026-10-06 14:18:30 +08:00

144 lines
5.3 KiB
JavaScript

// Upstream asset sources (GitHub dumps of the official client), URL helpers and
// local-path helpers shared by the asset pipeline (tools/fetch-assets.mjs).
//
// GitHub URLs support a prefix proxy and a jsDelivr fallback. jsDelivr does
// not serve the voice branch, but the prefix proxy can still be tried there.
// See docs/ASSETS.md for the full source list and credits.
export const DEFAULT_GITHUB_PROXY = 'https://gh-proxy.com/';
export function normalizeProxyPrefix(prefix = DEFAULT_GITHUB_PROXY) {
if (prefix.trim() === '') return '';
const url = new URL(prefix);
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
throw new Error('GitHub proxy prefix must be an HTTPS URL without credentials, query or fragment');
}
return url.href.endsWith('/') ? url.href : url.href + '/';
}
function isGithubDownloadUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' && !parsed.username && !parsed.password &&
['github.com', 'raw.githubusercontent.com', 'objects.githubusercontent.com'].includes(parsed.host);
} catch { return false; }
}
/**
* Legacy ledgers store the successful proxy URL. Spine page jobs derived from
* those URLs must use today's policy, not implicitly opt in to the old proxy.
* Recognize embedded public GitHub URLs even when the old custom prefix is no
* longer configured. An ordinary GitHub URL (including its path) stays intact.
*/
export function githubSourceUrl(url) {
let candidate = String(url);
while (candidate.startsWith('https://')) {
if (isGithubDownloadUrl(candidate)) return candidate;
const marker = candidate.indexOf('/https://', 8);
if (marker < 0) break;
candidate = candidate.slice(marker + 1);
}
return url;
}
/** Only public GitHub download URLs are sent to the proxy; never prefix twice. */
export function githubProxyUrl(url, prefix = DEFAULT_GITHUB_PROXY) {
const normalized = normalizeProxyPrefix(prefix);
if (!normalized || !isGithubDownloadUrl(url)) return null;
return normalized + url;
}
/** Ordered transport alternatives; direct mode retains the existing jsDelivr order. */
export function downloadUrls(url, { source = 'direct', proxyPrefix = DEFAULT_GITHUB_PROXY } = {}) {
url = githubSourceUrl(url);
const proxy = source === 'mirror' ? githubProxyUrl(url, proxyPrefix) : null;
return [...new Set([proxy, url, mirrorUrl(url)].filter(Boolean))];
}
/** Raw base URLs (always end with '/'). */
export const RAW = Object.freeze({
yuanyan: 'https://raw.githubusercontent.com/yuanyan3060/ArknightsGameResource/main/',
fexli: 'https://raw.githubusercontent.com/fexli/ArknightsResource/main/',
arkModels: 'https://raw.githubusercontent.com/isHarryh/Ark-Models/main/',
aa2: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/',
aa2voice: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/voice/assets/dyn/audio/sound_beta_2/',
fonts: 'https://raw.githubusercontent.com/TimWangZi/The-font-of-Arknights/master/font/',
gamedata: 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/',
});
const RAW_RE = /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/;
/**
* Map a raw.githubusercontent.com URL to its jsDelivr mirror.
* @param {string} url
* @returns {string|null} mirror URL, or null when no usable mirror exists
*/
export function mirrorUrl(url) {
const m = RAW_RE.exec(String(url));
if (!m) return null;
const [, owner, repo, branch, path] = m;
// jsDelivr does not serve the (huge) voice branch of ArknightsAssets2.
if (owner === 'ArknightsAssets' && branch === 'voice') return null;
return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path}`;
}
/**
* Percent-encode each segment of a repo-relative path ('[uc]x/a b.png' → '%5Buc%5Dx/a%20b.png').
* @param {string} relPath
* @returns {string}
*/
export function encodePath(relPath) {
return String(relPath).split('/').map((s) => encodeURIComponent(s)).join('/');
}
/**
* Join a base URL and a *decoded* repo-relative path, encoding the path.
* @param {string} base base URL ending with '/'
* @param {string} relPath decoded path
* @returns {string}
*/
export function joinUrl(base, relPath) {
return base + encodePath(relPath);
}
/**
* Sanitize a single file-name component for local storage / URLs:
* keeps [A-Za-z0-9._-], turns everything else (spaces, brackets, '#') into '_'.
* @param {string} name
* @returns {string}
*/
export function safeName(name) {
const s = String(name).replace(/[^A-Za-z0-9._-]/g, '_');
return s.length ? s : '_';
}
/**
* Site-root URL path for a file under public/assets.
* @param {string} rel path relative to public/assets (forward slashes)
* @returns {string} e.g. '/assets/char/avatar/char_002_amiya.png'
*/
export function assetUrl(rel) {
return '/assets/' + rel;
}
/**
* Directory part of a URL (everything up to and including the last '/').
* @param {string} url
* @returns {string}
*/
export function urlDir(url) {
const i = String(url).lastIndexOf('/');
return i >= 0 ? url.slice(0, i + 1) : '';
}
/**
* Last path component of a URL, percent-decoded.
* @param {string} url
* @returns {string}
*/
export function urlBase(url) {
const s = String(url).split('?')[0];
const last = s.slice(s.lastIndexOf('/') + 1);
try { return decodeURIComponent(last); } catch { return last; }
}