Files
memind/mindspace-canonical-url.mjs
T

102 lines
3.9 KiB
JavaScript

export const MINDSPACE_PUBLIC_ROOT = 'MindSpace';
export const MINDSPACE_PUBLIC_ZONE = 'public';
function urlError(message, code, details = {}) {
return Object.assign(new Error(message), { code, ...details });
}
function requiredSegment(value, field) {
const normalized = String(value ?? '').trim();
if (!normalized) {
throw urlError(`${field} is required`, 'invalid_mindspace_url_segment', { field });
}
if (normalized.includes('/') || normalized.includes('\\') || normalized.includes('\0')) {
throw urlError(`${field} must be a single URL segment`, 'invalid_mindspace_url_segment', {
field,
});
}
return normalized;
}
export function normalizeMindSpacePublicBaseUrl(publicBaseUrl) {
const normalized = String(publicBaseUrl ?? '').trim().replace(/\/+$/, '');
if (!normalized) {
throw urlError('publicBaseUrl is required', 'invalid_mindspace_public_base_url');
}
return normalized;
}
export function normalizeMindSpacePublicRelativePath(relativePath) {
const raw = String(relativePath ?? '').trim();
if (!raw) {
throw urlError('relativePath is required', 'invalid_mindspace_public_relative_path');
}
if (raw.includes('\0') || raw.includes('\\')) {
throw urlError('relativePath must use URL-style forward slashes', 'invalid_mindspace_public_relative_path');
}
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(raw) || raw.startsWith('//')) {
throw urlError('relativePath must not be an absolute URL', 'invalid_mindspace_public_relative_path');
}
const clean = raw.replace(/^\/+/, '');
const parts = clean.split('/').filter(Boolean);
if (parts.length === 0 || parts.includes('..')) {
throw urlError('relativePath must not traverse parent directories', 'invalid_mindspace_public_relative_path');
}
return parts.join('/');
}
export function createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath }) {
const base = normalizeMindSpacePublicBaseUrl(publicBaseUrl);
const owner = requiredSegment(ownerKey, 'ownerKey');
const relative = normalizeMindSpacePublicRelativePath(relativePath);
const encodedRelative = relative.split('/').map(encodeURIComponent).join('/');
return `${base}/${MINDSPACE_PUBLIC_ROOT}/${encodeURIComponent(owner)}/${encodedRelative}`;
}
export function createMindSpaceUserRootUrl({ publicBaseUrl, ownerKey }) {
const base = normalizeMindSpacePublicBaseUrl(publicBaseUrl);
const owner = requiredSegment(ownerKey, 'ownerKey');
return `${base}/${MINDSPACE_PUBLIC_ROOT}/${encodeURIComponent(owner)}/`;
}
export function createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey, filename }) {
const clean = normalizeMindSpacePublicRelativePath(filename);
const relative = clean.startsWith(`${MINDSPACE_PUBLIC_ZONE}/`)
? clean
: `${MINDSPACE_PUBLIC_ZONE}/${clean}`;
return createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath: relative });
}
export function parseMindSpacePublicUrl(inputUrl) {
const parsed = new URL(String(inputUrl ?? ''), 'https://mindspace.invalid');
const parts = parsed.pathname.split('/').filter(Boolean).map(decodeURIComponent);
const rootIndex = parts.indexOf(MINDSPACE_PUBLIC_ROOT);
if (rootIndex < 0 || parts.length < rootIndex + 3) {
throw urlError('URL is not a MindSpace public URL', 'not_mindspace_public_url');
}
const ownerKey = parts[rootIndex + 1];
const relativePath = normalizeMindSpacePublicRelativePath(parts.slice(rootIndex + 2).join('/'));
return {
ownerKey,
relativePath,
isPublicZone: relativePath === MINDSPACE_PUBLIC_ZONE || relativePath.startsWith(`${MINDSPACE_PUBLIC_ZONE}/`),
filename: relativePath.split('/').at(-1) ?? '',
};
}
export function canonicalizeMindSpacePublicUrl(inputUrl, { publicBaseUrl }) {
const parsed = parseMindSpacePublicUrl(inputUrl);
return createMindSpacePublicUrl({
publicBaseUrl,
ownerKey: parsed.ownerKey,
relativePath: parsed.relativePath,
});
}
export const mindspaceCanonicalUrlInternals = {
requiredSegment,
urlError,
};