mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
custom css works now
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Editor for CSS published through the signed-in account's profile fields.
|
||||
*
|
||||
* This is deliberately separate from Settings' viewer CSS: saving here is a
|
||||
* server mutation and changes what every plspace visitor sees on this profile.
|
||||
*/
|
||||
import { useAppServices } from '$lib/app-services'
|
||||
import {
|
||||
profileFieldLimits,
|
||||
publishedCssFieldValues,
|
||||
publishedCssFromFields,
|
||||
replacePublishedCssFields,
|
||||
} from '$lib/stores/theme.svelte'
|
||||
|
||||
const { endpoints, session } = useAppServices()
|
||||
|
||||
let editing = $state(false)
|
||||
let draft = $state('')
|
||||
let busy = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let saved = $state(false)
|
||||
let locallyPublishedCss = $state<string | null>(null)
|
||||
|
||||
const limits = $derived(profileFieldLimits(session.instance))
|
||||
const currentCss = $derived(
|
||||
locallyPublishedCss ?? publishedCssFromFields(session.me?.fields),
|
||||
)
|
||||
const chunksNeeded = $derived(publishedCssFieldValues(draft, limits.valueLength).length)
|
||||
|
||||
function decodeFieldValue(value: string): string {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = value
|
||||
return container.textContent ?? ''
|
||||
}
|
||||
|
||||
/** Credential-source fields are raw; fall back to decoding rendered fields. */
|
||||
function currentFields(): Array<{ name: string; value: string }> {
|
||||
if (session.me?.source?.fields) {
|
||||
return session.me.source.fields.map(({ name, value }) => ({ name, value }))
|
||||
}
|
||||
return (session.me?.fields ?? []).map(({ name, value }) => ({
|
||||
name,
|
||||
value: decodeFieldValue(value),
|
||||
}))
|
||||
}
|
||||
|
||||
function beginEditing(): void {
|
||||
draft = currentCss
|
||||
error = null
|
||||
saved = false
|
||||
editing = true
|
||||
}
|
||||
|
||||
async function publish(css: string): Promise<void> {
|
||||
if (!session.me || busy) return
|
||||
busy = true
|
||||
error = null
|
||||
saved = false
|
||||
try {
|
||||
const fields = replacePublishedCssFields(currentFields(), css, limits)
|
||||
session.me = await endpoints.updateProfileFields(session.api, fields)
|
||||
locallyPublishedCss = css.trim()
|
||||
draft = locallyPublishedCss
|
||||
editing = false
|
||||
saved = true
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not update your profile CSS.'
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
if (!confirm('Remove the CSS published on your profile?')) return
|
||||
void publish('')
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !session.signedIn || !session.me}
|
||||
<p class="empty-note">
|
||||
<a href="#/login">Sign in</a> to publish CSS on your profile.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="published-css-editor">
|
||||
<p class="notice">
|
||||
<strong>Public profile setting:</strong>
|
||||
saving here changes your profile for everyone who views it with plspace—not just this
|
||||
browser. Other clients may display the CSS as ordinary profile fields.
|
||||
</p>
|
||||
|
||||
{#if editing}
|
||||
<label class="field-label" for="published-css">CSS shown on your profile</label>
|
||||
<textarea
|
||||
id="published-css"
|
||||
class="css-editor"
|
||||
bind:value={draft}
|
||||
rows="16"
|
||||
spellcheck="false"
|
||||
aria-describedby="published-css-hint"
|
||||
></textarea>
|
||||
<p id="published-css-hint" class="field-hint">
|
||||
{draft.length.toLocaleString()} characters.
|
||||
{#if chunksNeeded > 1}
|
||||
plspace will store this in {chunksNeeded} ordered profile fields because
|
||||
{limits.valueLength.toLocaleString()} characters fit in each field on this server.
|
||||
{:else}
|
||||
This fits in one profile field.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="field-row">
|
||||
<button
|
||||
type="button"
|
||||
class="button button--primary"
|
||||
disabled={busy || !draft.trim()}
|
||||
onclick={() => void publish(draft)}
|
||||
>
|
||||
{busy ? 'Publishing…' : 'Publish CSS to my profile'}
|
||||
</button>
|
||||
<button type="button" class="button" disabled={busy} onclick={() => (editing = false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p>
|
||||
{#if currentCss}
|
||||
Your profile currently publishes
|
||||
<strong>{currentCss.length.toLocaleString()} characters</strong> of CSS.
|
||||
{:else}
|
||||
Your profile does not currently publish any CSS.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="error-note" role="alert">{error}</p>
|
||||
{:else if saved}
|
||||
<p class="notice" role="status">Your public profile CSS was updated.</p>
|
||||
{/if}
|
||||
|
||||
<div class="field-row">
|
||||
<button type="button" class="button button--primary" onclick={beginEditing}>
|
||||
{currentCss ? 'Edit published CSS' : 'Add CSS to my profile'}
|
||||
</button>
|
||||
{#if currentCss}
|
||||
<button type="button" class="button" disabled={busy} onclick={remove}>
|
||||
Remove published CSS
|
||||
</button>
|
||||
{/if}
|
||||
<a class="button" href={`#/@${session.me.acct}`}>View my profile</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { fireEvent, render, waitFor } from '@testing-library/svelte'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import type { CredentialAccount } from '$lib/api/types'
|
||||
import { publishedCssFromFields } from '$lib/stores/theme.svelte'
|
||||
import { account, session, testServices } from '$test/fixtures'
|
||||
import PublishedCssEditor from './PublishedCssEditor.svelte'
|
||||
|
||||
function credential(fields: Array<{ name: string; value: string }>): CredentialAccount {
|
||||
return {
|
||||
...account({ fields }),
|
||||
source: {
|
||||
note: '',
|
||||
fields,
|
||||
privacy: 'public',
|
||||
sensitive: false,
|
||||
language: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('PublishedCssEditor', () => {
|
||||
it('makes the public effect explicit and publishes without losing other fields', async () => {
|
||||
const me = credential([{ name: 'Website', value: 'https://example.test' }])
|
||||
const updateProfileFields = vi.fn(async (_api, fields: Array<{ name: string; value: string }>) =>
|
||||
credential(fields),
|
||||
)
|
||||
const services = testServices({
|
||||
session: session({
|
||||
token: 'token',
|
||||
me,
|
||||
signedIn: true,
|
||||
instance: {
|
||||
title: 'Pleroma',
|
||||
version: '2.9.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 10,
|
||||
max_profile_field_value_length: 50,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
endpoints: { updateProfileFields },
|
||||
})
|
||||
const view = render(PublishedCssEditor, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
expect(view.getByText(/everyone who views it with plspace/i)).toBeInTheDocument()
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Add CSS to my profile' }))
|
||||
|
||||
const css = '.one { color: red; }\n.two { color: green; }'
|
||||
await fireEvent.input(view.getByRole('textbox', { name: 'CSS shown on your profile' }), {
|
||||
target: { value: css },
|
||||
})
|
||||
expect(view.getByText(/store this in 2 ordered profile fields/i)).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Publish CSS to my profile' }))
|
||||
await waitFor(() => expect(updateProfileFields).toHaveBeenCalledOnce())
|
||||
|
||||
const sent = updateProfileFields.mock.calls[0][1]
|
||||
expect(sent[0]).toEqual({ name: 'Website', value: 'https://example.test' })
|
||||
expect(sent.slice(1).map((field) => field.name)).toEqual([
|
||||
'plspace:css1',
|
||||
'plspace:css2',
|
||||
])
|
||||
expect(publishedCssFromFields(sent.slice(1))).toBe(css)
|
||||
expect(await view.findByText('Your public profile CSS was updated.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads and replaces existing CSS chunks', async () => {
|
||||
const me = credential([
|
||||
{ name: 'Pronouns', value: 'they/them' },
|
||||
{ name: 'plspace:css1', value: '.old {' },
|
||||
{ name: 'plspace:css2', value: ' color: red; }' },
|
||||
])
|
||||
const updateProfileFields = vi.fn(async (_api, fields: Array<{ name: string; value: string }>) =>
|
||||
credential(fields),
|
||||
)
|
||||
const services = testServices({
|
||||
session: session({ token: 'token', me, signedIn: true }),
|
||||
endpoints: { updateProfileFields },
|
||||
})
|
||||
const view = render(PublishedCssEditor, {
|
||||
context: new Map([[APP_SERVICES, services]]),
|
||||
})
|
||||
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Edit published CSS' }))
|
||||
const editor = view.getByRole('textbox', { name: 'CSS shown on your profile' })
|
||||
expect(editor).toHaveValue('.old { color: red; }')
|
||||
|
||||
await fireEvent.input(editor, { target: { value: '.new { color: pink; }' } })
|
||||
await fireEvent.click(view.getByRole('button', { name: 'Publish CSS to my profile' }))
|
||||
await waitFor(() => expect(updateProfileFields).toHaveBeenCalledOnce())
|
||||
|
||||
const sent = updateProfileFields.mock.calls[0][1]
|
||||
expect(sent[0]).toEqual({ name: 'Pronouns', value: 'they/them' })
|
||||
expect(sent.slice(1).map((field) => field.name)).toEqual(['plspace:css1'])
|
||||
expect(publishedCssFromFields(sent.slice(1))).toBe('.new { color: pink; }')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiClient } from './client'
|
||||
import { updateProfileFields } from './endpoints'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('updateProfileFields', () => {
|
||||
it('uses the Pleroma/Mastodon indexed multipart representation', async () => {
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
expect(init?.method).toBe('PATCH')
|
||||
expect(init?.headers).toBeInstanceOf(Headers)
|
||||
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer token')
|
||||
expect(init?.body).toBeInstanceOf(FormData)
|
||||
const form = init?.body as FormData
|
||||
expect([...form.entries()]).toEqual([
|
||||
['fields_attributes[0][name]', 'Website'],
|
||||
['fields_attributes[0][value]', 'https://example.test'],
|
||||
['fields_attributes[1][name]', 'plspace:css1'],
|
||||
['fields_attributes[1][value]', 'body { color: pink; }'],
|
||||
])
|
||||
return new Response(JSON.stringify({ id: 'account-1', fields: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await updateProfileFields(new ApiClient('example.test', 'token'), [
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'plspace:css1', value: 'body { color: pink; }' },
|
||||
])
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://example.test/api/v1/accounts/update_credentials',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -85,6 +85,27 @@ export function verifyCredentials(api: ApiClient): Promise<CredentialAccount> {
|
||||
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials')
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the signed-in account's profile fields while leaving every other
|
||||
* credential untouched. Mastodon and Pleroma both accept this indexed
|
||||
* multipart shape; Pleroma-FE uses the same representation.
|
||||
*/
|
||||
export async function updateProfileFields(
|
||||
api: ApiClient,
|
||||
fields: Array<{ name: string; value: string }>,
|
||||
): Promise<CredentialAccount> {
|
||||
const form = new FormData()
|
||||
fields.forEach((field, index) => {
|
||||
form.append(`fields_attributes[${index}][name]`, field.name)
|
||||
form.append(`fields_attributes[${index}][value]`, field.value)
|
||||
})
|
||||
const { data } = await api.raw<CredentialAccount>('/api/v1/accounts/update_credentials', {
|
||||
method: 'PATCH',
|
||||
form,
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
|
||||
return api.get<Account>(`/api/v1/accounts/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
+21
-2
@@ -266,11 +266,30 @@ export interface InstanceInfo {
|
||||
max_characters?: number
|
||||
max_media_attachments?: number
|
||||
}
|
||||
accounts?: {
|
||||
max_profile_fields?: number
|
||||
/** Pleroma v2 names. */
|
||||
profile_field_name_limit?: number
|
||||
profile_field_value_limit?: number
|
||||
/** Mastodon-compatible aliases used by some implementations. */
|
||||
max_profile_field_name_length?: number
|
||||
max_profile_field_value_length?: number
|
||||
}
|
||||
}
|
||||
registrations?: boolean | { enabled?: boolean }
|
||||
contact_account?: Account | null
|
||||
/** Pleroma reports its "real" upstream here. */
|
||||
pleroma?: unknown
|
||||
/** Pleroma reports its "real" upstream and field limits here. */
|
||||
pleroma?: {
|
||||
metadata?: {
|
||||
fields_limits?: {
|
||||
max_fields?: number
|
||||
name_length?: number
|
||||
value_length?: number
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
|
||||
+226
-10
@@ -16,6 +16,8 @@
|
||||
* run script.
|
||||
*/
|
||||
|
||||
import type { InstanceInfo } from '../api/types'
|
||||
|
||||
const VIEWER_STYLE_ID = 'user-stylesheet'
|
||||
const PROFILE_STYLE_ID = 'profile-stylesheet'
|
||||
const STORAGE_KEY = 'plspace:viewer-css'
|
||||
@@ -26,6 +28,228 @@ 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']
|
||||
|
||||
const NAMESPACED_CSS_FIELD_PATTERN = /^plspace:css(\d+)$/i
|
||||
const LEGACY_CSS_FIELD_PATTERN = /^(css|style|layout|stylesheet)(?:[\s_-]+(\d+))?$/i
|
||||
const ENCODED_CSS_PREFIX = 'plspace-css-b64:'
|
||||
|
||||
export interface ProfileFieldLimits {
|
||||
maxFields: number
|
||||
nameLength: number
|
||||
valueLength: number
|
||||
}
|
||||
|
||||
/** Normalize Mastodon v2 and Pleroma extension shapes, with safe old-server defaults. */
|
||||
export function profileFieldLimits(instance: InstanceInfo | null): ProfileFieldLimits {
|
||||
const accounts = instance?.configuration?.accounts
|
||||
const pleroma = instance?.pleroma?.metadata?.fields_limits
|
||||
const isPleroma = Boolean(instance?.pleroma)
|
||||
return {
|
||||
maxFields: accounts?.max_profile_fields ?? pleroma?.max_fields ?? (isPleroma ? 10 : 4),
|
||||
nameLength:
|
||||
accounts?.profile_field_name_limit ??
|
||||
accounts?.max_profile_field_name_length ??
|
||||
pleroma?.name_length ??
|
||||
255,
|
||||
valueLength:
|
||||
accounts?.profile_field_value_limit ??
|
||||
accounts?.max_profile_field_value_length ??
|
||||
pleroma?.value_length ??
|
||||
(isPleroma ? 2048 : 255),
|
||||
}
|
||||
}
|
||||
|
||||
function fieldText(field: { value: string }): string {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = field.value
|
||||
for (const br of Array.from(container.querySelectorAll('br'))) {
|
||||
br.replaceWith(document.createTextNode('\n'))
|
||||
}
|
||||
for (const block of Array.from(container.querySelectorAll('p, div, li'))) {
|
||||
if (block.nextSibling) block.append(document.createTextNode('\n'))
|
||||
}
|
||||
return container.textContent ?? ''
|
||||
}
|
||||
|
||||
function cssField(
|
||||
field: { name: string },
|
||||
): { namespace: 'plspace' | 'legacy'; base: string; part: number } | null {
|
||||
const name = field.name.trim()
|
||||
const namespaced = NAMESPACED_CSS_FIELD_PATTERN.exec(name)
|
||||
if (namespaced) {
|
||||
return { namespace: 'plspace', base: 'css', part: Number(namespaced[1]) }
|
||||
}
|
||||
const legacy = LEGACY_CSS_FIELD_PATTERN.exec(name)
|
||||
if (!legacy) return null
|
||||
return {
|
||||
namespace: 'legacy',
|
||||
base: legacy[1].toLowerCase(),
|
||||
part: legacy[2] ? Number(legacy[2]) : 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reassemble a single CSS field or the ordered chunks written by plspace. */
|
||||
export function publishedCssFromFields(
|
||||
fields: Array<{ name: string; value: string }> | undefined,
|
||||
): string {
|
||||
if (!fields?.length) return ''
|
||||
const namespaced = fields
|
||||
.map((field, index) => ({ field, index, parsed: cssField(field) }))
|
||||
.filter((entry) => entry.parsed?.namespace === 'plspace')
|
||||
.sort((a, b) => (a.parsed!.part - b.parsed!.part) || (a.index - b.index))
|
||||
if (namespaced.length > 0) {
|
||||
const values = namespaced.map(({ field }) => fieldText(field))
|
||||
if (values.every((value) => value.startsWith(ENCODED_CSS_PREFIX))) {
|
||||
try {
|
||||
return decodeCss(
|
||||
values.map((value) => value.slice(ENCODED_CSS_PREFIX.length)).join(''),
|
||||
).trim()
|
||||
} catch {
|
||||
// Keep profiles readable if a field was edited or truncated elsewhere.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return values.join('').trim()
|
||||
}
|
||||
|
||||
for (const preferred of CSS_FIELD_NAMES) {
|
||||
const chunks = fields
|
||||
.map((field, index) => ({ field, index, parsed: cssField(field) }))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.parsed?.namespace === 'legacy' && entry.parsed.base === preferred,
|
||||
)
|
||||
.sort((a, b) => (a.parsed!.part - b.parsed!.part) || (a.index - b.index))
|
||||
if (chunks.length > 0) return chunks.map(({ field }) => fieldText(field)).join('').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers normalize bare LF line endings to CRLF when serializing FormData,
|
||||
* and Pleroma validates the decoded multipart value. Budget two characters for
|
||||
* each newline so a nominal 2,048-character chunk cannot arrive over the limit.
|
||||
*
|
||||
* Split on rule boundaries when possible while otherwise retaining the source.
|
||||
* Chunks are concatenated without a separator, including when a very long
|
||||
* quoted data URI has to be split in its middle.
|
||||
*/
|
||||
export function splitPublishedCss(css: string, limit: number): string[] {
|
||||
const source = css.replace(/\r\n?/g, '\n').trim()
|
||||
if (!source) return []
|
||||
const safeLimit = Math.max(1, Math.floor(limit))
|
||||
const chunks: string[] = []
|
||||
let rest = source
|
||||
|
||||
while (multipartFieldLength(rest) > safeLimit) {
|
||||
let windowEnd = 0
|
||||
let encodedLength = 0
|
||||
while (windowEnd < rest.length) {
|
||||
const width = rest[windowEnd] === '\n' ? 2 : 1
|
||||
if (encodedLength + width > safeLimit) break
|
||||
encodedLength += width
|
||||
windowEnd += 1
|
||||
}
|
||||
// A one-character limit can still make progress on a newline.
|
||||
if (windowEnd === 0) windowEnd = 1
|
||||
|
||||
const window = rest.slice(0, windowEnd)
|
||||
let cut = window.lastIndexOf('}')
|
||||
if (cut >= 0) cut += 1
|
||||
else {
|
||||
cut = window.lastIndexOf('\n')
|
||||
if (cut <= 0) cut = windowEnd
|
||||
}
|
||||
chunks.push(rest.slice(0, cut))
|
||||
rest = rest.slice(cut)
|
||||
}
|
||||
if (rest) chunks.push(rest)
|
||||
return chunks
|
||||
}
|
||||
|
||||
/** Length after the newline normalization used for multipart form fields. */
|
||||
export function multipartFieldLength(value: string): number {
|
||||
let length = value.length
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value[index] === '\n' && value[index - 1] !== '\r') length += 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
function encodeCss(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
let binary = ''
|
||||
const windowSize = 0x8000
|
||||
for (let index = 0; index < bytes.length; index += windowSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + windowSize))
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function decodeCss(value: string): string {
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
|
||||
const binary = atob(padded)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode published CSS before placing it in profile fields.
|
||||
*
|
||||
* Pleroma linkifies raw profile-field values before enforcing its configured
|
||||
* value limit. CSS commonly contains URLs and hashtag-shaped tokens such as
|
||||
* `#fff`, so a raw value under the advertised limit can expand into HTML over
|
||||
* that limit. Base64url has no linkifiable syntax, making its server-side
|
||||
* length identical to the length measured here.
|
||||
*/
|
||||
export function publishedCssFieldValues(css: string, limit: number): string[] {
|
||||
const source = css.replace(/\r\n?/g, '\n').trim()
|
||||
if (!source) return []
|
||||
|
||||
const capacity = Math.floor(limit) - ENCODED_CSS_PREFIX.length
|
||||
if (capacity < 1) {
|
||||
throw new Error('This server’s profile field-value limit is too small for encoded CSS.')
|
||||
}
|
||||
|
||||
const encoded = encodeCss(source)
|
||||
const values: string[] = []
|
||||
for (let index = 0; index < encoded.length; index += capacity) {
|
||||
values.push(ENCODED_CSS_PREFIX + encoded.slice(index, index + capacity))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve normal profile fields and replace all prior CSS chunks atomically.
|
||||
* Legacy CSS fields are migrated to the unambiguous `plspace:css1` namespace.
|
||||
*/
|
||||
export function replacePublishedCssFields(
|
||||
fields: Array<{ name: string; value: string }>,
|
||||
css: string,
|
||||
limits: ProfileFieldLimits,
|
||||
): Array<{ name: string; value: string }> {
|
||||
const existingCss = fields.map((field) => ({ field, parsed: cssField(field) }))
|
||||
const ordinary = existingCss
|
||||
.filter((entry) => !entry.parsed)
|
||||
.map(({ field }) => ({ name: field.name, value: field.value }))
|
||||
const chunks = publishedCssFieldValues(css, limits.valueLength)
|
||||
const cssFields = chunks.map((value, index) => ({
|
||||
name: `plspace:css${index + 1}`,
|
||||
value,
|
||||
}))
|
||||
|
||||
if (ordinary.length + cssFields.length > limits.maxFields) {
|
||||
const available = Math.max(0, limits.maxFields - ordinary.length)
|
||||
throw new Error(
|
||||
`This server has room for ${available} CSS field${available === 1 ? '' : 's'}, but this stylesheet needs ${cssFields.length}. Shorten it or remove another profile field.`,
|
||||
)
|
||||
}
|
||||
if (cssFields.some((field) => field.name.length > limits.nameLength)) {
|
||||
throw new Error('This server’s profile field-name limit is too small for chunked CSS.')
|
||||
}
|
||||
return [...ordinary, ...cssFields]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get (or create) a style element, always moving it to the end of `<head>`.
|
||||
*
|
||||
@@ -194,14 +418,6 @@ export const theme = new Theme()
|
||||
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
|
||||
const css = publishedCssFromFields(fields)
|
||||
return css.includes('{') ? css : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
profileCssFromFields,
|
||||
profileFieldLimits,
|
||||
multipartFieldLength,
|
||||
publishedCssFieldValues,
|
||||
publishedCssFromFields,
|
||||
replacePublishedCssFields,
|
||||
splitPublishedCss,
|
||||
} from './theme.svelte'
|
||||
|
||||
describe('published profile CSS fields', () => {
|
||||
it('reads ordinary and chunked CSS fields in numeric order', () => {
|
||||
const fields = [
|
||||
{ name: 'Website', value: '<a href="https://example.test">example</a>' },
|
||||
{ name: 'plspace:css3', value: '<p>.three { color: blue; }</p>' },
|
||||
{ name: 'plspace:css1', value: '<p>.one { color: red; }</p>' },
|
||||
{ name: 'plspace:css2', value: '<p>.two { color: green; }</p>' },
|
||||
]
|
||||
|
||||
expect(publishedCssFromFields(fields)).toBe(
|
||||
'.one { color: red; }.two { color: green; }.three { color: blue; }',
|
||||
)
|
||||
expect(profileCssFromFields(fields)).toContain('.three { color: blue; }')
|
||||
})
|
||||
|
||||
it('splits long stylesheets on complete rule boundaries', () => {
|
||||
const css = '.one { color: red; }\n.two { color: green; }\n.three { color: blue; }'
|
||||
const chunks = splitPublishedCss(css, 42)
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.every((chunk) => multipartFieldLength(chunk) <= 42)).toBe(true)
|
||||
expect(chunks.join('')).toBe(css)
|
||||
})
|
||||
|
||||
it('budgets for FormData expanding line feeds to CRLF', () => {
|
||||
const css = Array.from({ length: 20 }, (_, index) => `.r${index} {\n color: pink;\n}`).join(
|
||||
'\n',
|
||||
)
|
||||
const chunks = splitPublishedCss(css, 80)
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1)
|
||||
expect(chunks.every((chunk) => multipartFieldLength(chunk) <= 80)).toBe(true)
|
||||
expect(chunks.join('')).toBe(css)
|
||||
})
|
||||
|
||||
it('stores CSS as non-linkifiable chunks and decodes Unicode exactly', () => {
|
||||
const css =
|
||||
'.profile { color: #fff; background: url("https://example.test/image.png"); }\n/* 日本語 🌸 */'
|
||||
const values = publishedCssFieldValues(css, 48)
|
||||
|
||||
expect(values.length).toBeGreaterThan(1)
|
||||
expect(values.every((value) => value.length <= 48)).toBe(true)
|
||||
expect(values.every((value) => /^plspace-css-b64:[A-Za-z0-9_-]+$/.test(value))).toBe(true)
|
||||
expect(
|
||||
publishedCssFromFields(
|
||||
values.map((value, index) => ({ name: `plspace:css${index + 1}`, value })),
|
||||
),
|
||||
).toBe(css)
|
||||
})
|
||||
|
||||
it('replaces old CSS chunks while preserving unrelated profile fields', () => {
|
||||
const result = replacePublishedCssFields(
|
||||
[
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'style', value: 'old' },
|
||||
{ name: 'style 2', value: 'old continuation' },
|
||||
{ name: 'Pronouns', value: 'they/them' },
|
||||
],
|
||||
'.one { color: red; }\n.two { color: green; }',
|
||||
{ maxFields: 6, nameLength: 255, valueLength: 55 },
|
||||
)
|
||||
|
||||
expect(result.slice(0, 2)).toEqual([
|
||||
{ name: 'Website', value: 'https://example.test' },
|
||||
{ name: 'Pronouns', value: 'they/them' },
|
||||
])
|
||||
expect(result.slice(2).map((field) => field.name)).toEqual([
|
||||
'plspace:css1',
|
||||
'plspace:css2',
|
||||
])
|
||||
expect(publishedCssFromFields(result.slice(2))).toBe(
|
||||
'.one { color: red; }\n.two { color: green; }',
|
||||
)
|
||||
})
|
||||
|
||||
it('removes only CSS fields when publishing an empty value', () => {
|
||||
expect(
|
||||
replacePublishedCssFields(
|
||||
[
|
||||
{ name: 'css', value: 'body {}' },
|
||||
{ name: 'plspace:css2', value: '.more {}' },
|
||||
{ name: 'Location', value: 'Tokyo' },
|
||||
],
|
||||
'',
|
||||
{ maxFields: 4, nameLength: 255, valueLength: 255 },
|
||||
),
|
||||
).toEqual([{ name: 'Location', value: 'Tokyo' }])
|
||||
})
|
||||
|
||||
it('rejects a stylesheet that exceeds the server field count', () => {
|
||||
expect(() =>
|
||||
replacePublishedCssFields(
|
||||
[{ name: 'Website', value: 'https://example.test' }],
|
||||
'.a{color:red}.b{color:blue}.c{color:green}',
|
||||
{ maxFields: 2, nameLength: 255, valueLength: 30 },
|
||||
),
|
||||
).toThrow(/room for 1 CSS field/)
|
||||
})
|
||||
|
||||
it('normalizes both Mastodon and Pleroma field-limit shapes', () => {
|
||||
expect(
|
||||
profileFieldLimits({
|
||||
title: 'Mastodon',
|
||||
version: '4.5.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 4,
|
||||
max_profile_field_name_length: 255,
|
||||
max_profile_field_value_length: 500,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ maxFields: 4, nameLength: 255, valueLength: 500 })
|
||||
|
||||
expect(
|
||||
profileFieldLimits({
|
||||
title: 'Pleroma',
|
||||
version: '2.9.0',
|
||||
configuration: {
|
||||
accounts: {
|
||||
max_profile_fields: 10,
|
||||
profile_field_name_limit: 512,
|
||||
profile_field_value_limit: 2048,
|
||||
},
|
||||
},
|
||||
pleroma: {
|
||||
metadata: {
|
||||
fields_limits: { max_fields: 9, name_length: 500, value_length: 2000 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ maxFields: 10, nameLength: 512, valueLength: 2048 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { account } from '$test/fixtures'
|
||||
import { buildProfileView } from './profile'
|
||||
|
||||
describe('buildProfileView', () => {
|
||||
it('hides namespaced plspace storage fields from visible profile details', () => {
|
||||
const profile = buildProfileView(
|
||||
account({
|
||||
fields: [
|
||||
{ name: 'Website', value: '<a href="https://example.test">example.test</a>' },
|
||||
{ name: 'plspace:css1', value: 'plspace-css-b64:YWJj' },
|
||||
{ name: ' PLSPACE:future-setting ', value: 'internal' },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(profile.details).toHaveLength(1)
|
||||
expect(profile.details[0].name).toBe('Website')
|
||||
expect(profile.details[0].value).toContain('example.test')
|
||||
})
|
||||
})
|
||||
@@ -132,6 +132,10 @@ export function buildProfileView(account: Account): ProfileView {
|
||||
|
||||
for (const field of account.fields ?? []) {
|
||||
const key = field.name.trim().toLowerCase()
|
||||
// `plspace:` fields are application storage, not user-facing profile
|
||||
// details. Keep them on the Account for features such as published CSS,
|
||||
// but never turn them into visible name/value rows.
|
||||
if (key.startsWith('plspace:')) continue
|
||||
if (CHROME_FIELDS.has(key)) continue
|
||||
|
||||
const value = renderHtml(field.value, { emojis: account.emojis })
|
||||
|
||||
+18
-23
@@ -13,6 +13,7 @@
|
||||
import { instanceDomain } from '$lib/api/endpoints'
|
||||
import { displayNameOf, profilePath } from '$lib/util/profile'
|
||||
import Module from '$components/common/Module.svelte'
|
||||
import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte'
|
||||
|
||||
const { session, theme } = useAppServices()
|
||||
|
||||
@@ -130,11 +131,15 @@
|
||||
</ul>
|
||||
</Module>
|
||||
|
||||
<Module title="Your CSS" variant="band">
|
||||
<Module title="CSS for this browser only" variant="band">
|
||||
<p class="notice">
|
||||
<strong>Private viewing setting:</strong>
|
||||
this changes how plspace looks only for you, in this browser. It does not update your
|
||||
profile, and nobody else can see it.
|
||||
</p>
|
||||
<p>
|
||||
This is applied to every page you view in plspace. Overriding the custom properties in
|
||||
<code>styles/tokens.css</code> retints the entire app; the class hooks below let you go
|
||||
further.
|
||||
Overriding the custom properties in <code>styles/tokens.css</code> retints the entire app;
|
||||
the class hooks below let you go further.
|
||||
</p>
|
||||
|
||||
<label class="visually-hidden" for="viewer-css">Your CSS</label>
|
||||
@@ -174,32 +179,22 @@
|
||||
</span>
|
||||
</label>
|
||||
<p class="field-hint">
|
||||
A profile can publish a stylesheet by putting CSS in a profile field named
|
||||
{#each CSS_FIELD_NAMES as name, index (name)}<code>{name}</code>{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}.
|
||||
plspace publishes layouts in ordered fields named <code>plspace:css1</code>,
|
||||
<code>plspace:css2</code>, and so on. Legacy
|
||||
{#each CSS_FIELD_NAMES as name, index (name)}<code>{name}</code>{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}
|
||||
fields are still understood.
|
||||
Their rules are rewritten to apply only inside <code>{PROFILE_SCOPE}</code>, so a profile
|
||||
can restyle its own page but not the rest of plspace.
|
||||
</p>
|
||||
</Module>
|
||||
|
||||
<Module title="Publish your own layout" variant="band">
|
||||
<Module title="CSS published on your profile" variant="band">
|
||||
<p>
|
||||
Add a profile field on <strong>{domain || 'your server'}</strong> named <code>css</code> and
|
||||
paste a stylesheet into its value. Anyone viewing your profile in plspace sees it. Since
|
||||
it's an ordinary profile field, it survives elsewhere too — other clients just show it
|
||||
as text.
|
||||
This editor writes the layout into profile fields on
|
||||
<strong>{domain || 'your server'}</strong>. Anyone who visits your profile with plspace
|
||||
will receive it automatically.
|
||||
</p>
|
||||
{#if session.signedIn}
|
||||
<p>
|
||||
<a
|
||||
class="button"
|
||||
href={`https://${session.host}/settings/profile`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Edit your profile on {domain}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
<PublishedCssEditor />
|
||||
</Module>
|
||||
|
||||
<Module title="Class reference" variant="band">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render } from '@testing-library/svelte'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_SERVICES } from '$lib/app-services'
|
||||
import { testServices } from '$test/fixtures'
|
||||
import Settings from './Settings.svelte'
|
||||
|
||||
describe('Settings CSS language', () => {
|
||||
it('distinguishes private viewer CSS from publicly published profile CSS', () => {
|
||||
const view = render(Settings, {
|
||||
context: new Map([[APP_SERVICES, testServices()]]),
|
||||
})
|
||||
|
||||
expect(
|
||||
view.getByRole('heading', { name: 'CSS for this browser only' }),
|
||||
).toBeInTheDocument()
|
||||
expect(view.getByText(/nobody else can see it/i)).toBeInTheDocument()
|
||||
expect(
|
||||
view.getByRole('heading', { name: 'CSS published on your profile' }),
|
||||
).toBeInTheDocument()
|
||||
expect(view.getByText(/anyone who visits your profile with plspace/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user