Files
plspace/src/lib/stores/theme.svelte.ts
T
2026-07-29 09:16:38 +09:00

208 lines
7.9 KiB
TypeScript

/**
* User styling.
*
* The whole point of a MySpace profile was that you could wreck it with your
* own CSS. Two independent layers exist here:
*
* 1. **Viewer CSS** (`#user-stylesheet`) — what *you* set in Settings. Applies
* everywhere you browse and is stored locally.
* 2. **Profile CSS** (`#profile-stylesheet`) — what the *account being viewed*
* publishes, read from a profile field named `css` / `style` / `layout`.
* Cleared on navigation so it can never leak onto another page.
*
* Profile CSS is untrusted third-party input, so it is filtered: no `@import`,
* no `url()` pointing anywhere but https/data-images, no escaping the profile
* subtree. It is CSS only — there is no path here by which a remote profile can
* run script.
*/
const VIEWER_STYLE_ID = 'user-stylesheet'
const PROFILE_STYLE_ID = 'profile-stylesheet'
const STORAGE_KEY = 'plspace:viewer-css'
/** Root class the profile page carries; all profile CSS is confined to it. */
export const PROFILE_SCOPE = '.profile-page'
/** Field names checked, in order, for a profile's published stylesheet. */
export const CSS_FIELD_NAMES = ['css', 'style', 'layout', 'stylesheet']
/**
* Get (or create) a style element, always moving it to the end of `<head>`.
*
* The relocation is the important part. The app's own stylesheet is injected
* into `<head>` when the bundle loads — after the `<style id="user-stylesheet">`
* declared in index.html — so a user rule with the same specificity as a
* shipped one would silently lose the tie. Re-appending puts user CSS last in
* document order, which is what makes plain single-class overrides work without
* anyone reaching for `!important`.
*/
function styleElement(id: string): HTMLStyleElement {
let element = document.getElementById(id) as HTMLStyleElement | null
if (!element) {
element = document.createElement('style')
element.id = id
}
document.head.append(element)
return element
}
/**
* Strip constructs a hostile profile could abuse.
*
* This is defence in depth rather than a sandbox: the browser's own CSS parser
* is the real boundary, and CSS cannot execute script in any supported browser.
* What it *can* do is phone home via background images and cover the page, so
* remote resources and fixed positioning are what get removed.
*/
export function sanitizeCss(css: string): string {
return (
css
// Comments first, so they can't hide the patterns below.
.replace(/\/\*[\s\S]*?\*\//g, '')
// `@import` would pull in an unbounded, unfiltered stylesheet.
.replace(/@import[^;]*;?/gi, '')
// Legacy IE vectors, still parsed by nothing but worth removing.
.replace(/expression\s*\(/gi, 'void(')
.replace(/behaviou?r\s*:/gi, '_behavior:')
.replace(/-moz-binding\s*:/gi, '_binding:')
// Only allow images from https or inline data URIs.
.replace(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi, (whole, _quote: string, url: string) =>
/^(https:\/\/|data:image\/)/i.test(url.trim()) ? whole : 'none',
)
// Keep the page navigable: no viewport-covering overlays.
.replace(/position\s*:\s*fixed/gi, 'position: static')
)
}
/**
* Prefix every selector so the rules cannot reach outside the profile subtree.
*
* A selector list is anything between a block boundary and the `{` that opens
* its body. Matching after `{` as well as after `}` is what catches rules
* *nested inside* an at-block — without it, `@media (…) { .site-nav { … } }`
* would slip through unscoped and let a profile restyle the whole app. At-rule
* preludes themselves are skipped, since `[^{}@]` cannot span the `@`.
*/
export function scopeCss(css: string, scope: string): string {
// `@keyframes` steps are `from`/`to`/`50%`, not selectors — prefixing them
// produces a block the parser discards, silently killing every animation.
// Lift them out, scope everything else, then put them back.
const keyframes: string[] = []
const withoutKeyframes = css.replace(
/@(?:-\w+-)?keyframes\s+[^{]+\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/gi,
(block) => {
keyframes.push(block)
// The placeholder must end in `}` so the *next* rule still sits on a
// block boundary the scoping regex recognises, and must contain `@` so
// the placeholder itself is never mistaken for a selector.
return `@plspace-keyframes-${keyframes.length - 1}{}`
},
)
const scoped = scopeSelectors(withoutKeyframes, scope)
return scoped.replace(
/@plspace-keyframes-(\d+)\{\}/g,
(_whole, index: string) => keyframes[Number(index)],
)
}
function scopeSelectors(css: string, scope: string): string {
return css.replace(/(^|[{}])([^{}@]+)\{/g, (whole, close: string, selectors: string) => {
const trimmed = selectors.trim()
if (!trimmed) return whole
const scoped = trimmed
.split(',')
.map((selector) => {
const one = selector.trim()
if (!one) return ''
// Let authors restyle the page background by writing `body`/`html`.
if (/^(html|body)$/i.test(one)) return scope
if (one.startsWith(scope)) return one
return `${scope} ${one}`
})
.filter(Boolean)
.join(', ')
return `${close}${scoped}{`
})
}
/**
* There is no built-in light/dark switch, on purpose.
*
* The 2005 palette is the design, and a dark variant is nothing more than a set
* of token overrides — which is precisely what a preset already is. Shipping a
* hardcoded toggle would have meant one dark theme nobody could edit, sitting
* beside a styling system built for exactly this. Dark mode is the Midnight
* preset in lib/themes.ts; users can edit it or write their own.
*
* A preset that wants native form controls to follow suit can say
* `:root { color-scheme: dark }` in its own CSS — viewer CSS is unscoped.
*/
class Theme {
/** CSS the viewer wrote for themselves. */
viewerCss = $state('')
/** Whether to honour CSS published by the profiles you visit. */
allowProfileCss = $state(true)
constructor() {
if (typeof localStorage === 'undefined') return
this.viewerCss = localStorage.getItem(STORAGE_KEY) ?? ''
this.allowProfileCss = localStorage.getItem('plspace:allow-profile-css') !== 'false'
if (this.viewerCss) this.applyViewerCss()
}
setViewerCss(css: string): void {
this.viewerCss = css
localStorage.setItem(STORAGE_KEY, css)
this.applyViewerCss()
}
setAllowProfileCss(allow: boolean): void {
this.allowProfileCss = allow
localStorage.setItem('plspace:allow-profile-css', String(allow))
if (!allow) this.clearProfileCss()
}
private applyViewerCss(): void {
// Not scoped: this is the viewer's own machine and their own choice.
styleElement(VIEWER_STYLE_ID).textContent = sanitizeCss(this.viewerCss)
}
/** Apply CSS published by the profile currently on screen. */
applyProfileCss(css: string | null | undefined): void {
if (!css || !this.allowProfileCss) {
this.clearProfileCss()
return
}
styleElement(PROFILE_STYLE_ID).textContent = scopeCss(sanitizeCss(css), PROFILE_SCOPE)
// Keep the viewer's own sheet last: their machine, their final say.
if (this.viewerCss) styleElement(VIEWER_STYLE_ID)
}
clearProfileCss(): void {
const element = document.getElementById(PROFILE_STYLE_ID)
if (element) element.textContent = ''
}
}
export const theme = new Theme()
/** Pull a published stylesheet out of an account's profile fields. */
export function profileCssFromFields(
fields: Array<{ name: string; value: string }> | undefined,
): string | null {
if (!fields?.length) return null
for (const field of fields) {
if (!CSS_FIELD_NAMES.includes(field.name.trim().toLowerCase())) continue
// Field values arrive as HTML; take the text and undo entity escaping.
const container = document.createElement('div')
container.innerHTML = field.value
const text = (container.textContent ?? '').trim()
if (text.includes('{')) return text
}
return null
}