mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
208 lines
7.9 KiB
TypeScript
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
|
|
}
|