custom css works now

This commit is contained in:
Moon.eth
2026-07-29 16:03:12 +09:00
parent f51c576952
commit 81176654ba
11 changed files with 777 additions and 35 deletions
@@ -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; }')
})
})
+40
View File
@@ -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),
)
})
})
+21
View File
@@ -85,6 +85,27 @@ export function verifyCredentials(api: ApiClient): Promise<CredentialAccount> {
return api.get<CredentialAccount>('/api/v1/accounts/verify_credentials') 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> { export function fetchAccount(api: ApiClient, id: string): Promise<Account> {
return api.get<Account>(`/api/v1/accounts/${encodeURIComponent(id)}`) return api.get<Account>(`/api/v1/accounts/${encodeURIComponent(id)}`)
} }
+21 -2
View File
@@ -266,11 +266,30 @@ export interface InstanceInfo {
max_characters?: number max_characters?: number
max_media_attachments?: 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 } registrations?: boolean | { enabled?: boolean }
contact_account?: Account | null contact_account?: Account | null
/** Pleroma reports its "real" upstream here. */ /** Pleroma reports its "real" upstream and field limits here. */
pleroma?: unknown pleroma?: {
metadata?: {
fields_limits?: {
max_fields?: number
name_length?: number
value_length?: number
}
[key: string]: unknown
}
[key: string]: unknown
}
} }
export interface SearchResults { export interface SearchResults {
+226 -10
View File
@@ -16,6 +16,8 @@
* run script. * run script.
*/ */
import type { InstanceInfo } from '../api/types'
const VIEWER_STYLE_ID = 'user-stylesheet' const VIEWER_STYLE_ID = 'user-stylesheet'
const PROFILE_STYLE_ID = 'profile-stylesheet' const PROFILE_STYLE_ID = 'profile-stylesheet'
const STORAGE_KEY = 'plspace:viewer-css' 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. */ /** Field names checked, in order, for a profile's published stylesheet. */
export const CSS_FIELD_NAMES = ['css', 'style', 'layout', '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 servers 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 servers 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>`. * 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( export function profileCssFromFields(
fields: Array<{ name: string; value: string }> | undefined, fields: Array<{ name: string; value: string }> | undefined,
): string | null { ): string | null {
if (!fields?.length) return null const css = publishedCssFromFields(fields)
for (const field of fields) { return css.includes('{') ? css : null
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
} }
+144
View File
@@ -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 })
})
})
+21
View File
@@ -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')
})
})
+4
View File
@@ -132,6 +132,10 @@ export function buildProfileView(account: Account): ProfileView {
for (const field of account.fields ?? []) { for (const field of account.fields ?? []) {
const key = field.name.trim().toLowerCase() 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 if (CHROME_FIELDS.has(key)) continue
const value = renderHtml(field.value, { emojis: account.emojis }) const value = renderHtml(field.value, { emojis: account.emojis })
+18 -23
View File
@@ -13,6 +13,7 @@
import { instanceDomain } from '$lib/api/endpoints' import { instanceDomain } from '$lib/api/endpoints'
import { displayNameOf, profilePath } from '$lib/util/profile' import { displayNameOf, profilePath } from '$lib/util/profile'
import Module from '$components/common/Module.svelte' import Module from '$components/common/Module.svelte'
import PublishedCssEditor from '$components/profile/PublishedCssEditor.svelte'
const { session, theme } = useAppServices() const { session, theme } = useAppServices()
@@ -130,11 +131,15 @@
</ul> </ul>
</Module> </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> <p>
This is applied to every page you view in plspace. Overriding the custom properties in Overriding the custom properties in <code>styles/tokens.css</code> retints the entire app;
<code>styles/tokens.css</code> retints the entire app; the class hooks below let you go the class hooks below let you go further.
further.
</p> </p>
<label class="visually-hidden" for="viewer-css">Your CSS</label> <label class="visually-hidden" for="viewer-css">Your CSS</label>
@@ -174,32 +179,22 @@
</span> </span>
</label> </label>
<p class="field-hint"> <p class="field-hint">
A profile can publish a stylesheet by putting CSS in a profile field named plspace publishes layouts in ordered fields named <code>plspace:css1</code>,
{#each CSS_FIELD_NAMES as name, index (name)}<code>{name}</code>{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}. <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 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. can restyle its own page but not the rest of plspace.
</p> </p>
</Module> </Module>
<Module title="Publish your own layout" variant="band"> <Module title="CSS published on your profile" variant="band">
<p> <p>
Add a profile field on <strong>{domain || 'your server'}</strong> named <code>css</code> and This editor writes the layout into profile fields on
paste a stylesheet into its value. Anyone viewing your profile in plspace sees it. Since <strong>{domain || 'your server'}</strong>. Anyone who visits your profile with plspace
it's an ordinary profile field, it survives elsewhere too &mdash; other clients just show it will receive it automatically.
as text.
</p> </p>
{#if session.signedIn} <PublishedCssEditor />
<p>
<a
class="button"
href={`https://${session.host}/settings/profile`}
target="_blank"
rel="noopener noreferrer"
>
Edit your profile on {domain}
</a>
</p>
{/if}
</Module> </Module>
<Module title="Class reference" variant="band"> <Module title="Class reference" variant="band">
+22
View File
@@ -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()
})
})