diff --git a/src/components/profile/PublishedCssEditor.svelte b/src/components/profile/PublishedCssEditor.svelte
new file mode 100644
index 0000000..70135c6
--- /dev/null
+++ b/src/components/profile/PublishedCssEditor.svelte
@@ -0,0 +1,158 @@
+
+
+{#if !session.signedIn || !session.me}
+
+ Sign in to publish CSS on your profile.
+
+{:else}
+
+
+ Public profile setting:
+ 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.
+
+
+ {#if editing}
+
CSS shown on your profile
+
+
+ {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}
+
+
+ {#if error}
+
{error}
+ {/if}
+
+
+ void publish(draft)}
+ >
+ {busy ? 'Publishing…' : 'Publish CSS to my profile'}
+
+ (editing = false)}>
+ Cancel
+
+
+ {:else}
+
+ {#if currentCss}
+ Your profile currently publishes
+ {currentCss.length.toLocaleString()} characters of CSS.
+ {:else}
+ Your profile does not currently publish any CSS.
+ {/if}
+
+
+ {#if error}
+
{error}
+ {:else if saved}
+
Your public profile CSS was updated.
+ {/if}
+
+
+
+ {currentCss ? 'Edit published CSS' : 'Add CSS to my profile'}
+
+ {#if currentCss}
+
+ Remove published CSS
+
+ {/if}
+
View my profile
+
+ {/if}
+
+{/if}
diff --git a/src/components/profile/PublishedCssEditor.test.ts b/src/components/profile/PublishedCssEditor.test.ts
new file mode 100644
index 0000000..0d7a264
--- /dev/null
+++ b/src/components/profile/PublishedCssEditor.test.ts
@@ -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; }')
+ })
+})
diff --git a/src/lib/api/endpoints.test.ts b/src/lib/api/endpoints.test.ts
new file mode 100644
index 0000000..f1631e2
--- /dev/null
+++ b/src/lib/api/endpoints.test.ts
@@ -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),
+ )
+ })
+})
diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts
index 27d7263..570d01c 100644
--- a/src/lib/api/endpoints.ts
+++ b/src/lib/api/endpoints.ts
@@ -85,6 +85,27 @@ export function verifyCredentials(api: ApiClient): Promise {
return api.get('/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 {
+ 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('/api/v1/accounts/update_credentials', {
+ method: 'PATCH',
+ form,
+ })
+ return data
+}
+
export function fetchAccount(api: ApiClient, id: string): Promise {
return api.get(`/api/v1/accounts/${encodeURIComponent(id)}`)
}
diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts
index 6764553..39f2893 100644
--- a/src/lib/api/types.ts
+++ b/src/lib/api/types.ts
@@ -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 {
diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts
index 5a3d507..ebf68ce 100644
--- a/src/lib/stores/theme.svelte.ts
+++ b/src/lib/stores/theme.svelte.ts
@@ -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 ``.
*
@@ -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
}
diff --git a/src/lib/stores/theme.test.ts b/src/lib/stores/theme.test.ts
new file mode 100644
index 0000000..1e49539
--- /dev/null
+++ b/src/lib/stores/theme.test.ts
@@ -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: 'example ' },
+ { name: 'plspace:css3', value: '.three { color: blue; }
' },
+ { name: 'plspace:css1', value: '.one { color: red; }
' },
+ { name: 'plspace:css2', value: '.two { color: green; }
' },
+ ]
+
+ 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 })
+ })
+})
diff --git a/src/lib/util/profile.test.ts b/src/lib/util/profile.test.ts
new file mode 100644
index 0000000..17f2410
--- /dev/null
+++ b/src/lib/util/profile.test.ts
@@ -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: 'example.test ' },
+ { 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')
+ })
+})
diff --git a/src/lib/util/profile.ts b/src/lib/util/profile.ts
index dbf5f0e..d155870 100644
--- a/src/lib/util/profile.ts
+++ b/src/lib/util/profile.ts
@@ -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 })
diff --git a/src/routes/Settings.svelte b/src/routes/Settings.svelte
index d9d41dc..385dd1a 100644
--- a/src/routes/Settings.svelte
+++ b/src/routes/Settings.svelte
@@ -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 @@
-
+
+
+ Private viewing setting:
+ this changes how plspace looks only for you, in this browser. It does not update your
+ profile, and nobody else can see it.
+
- This is applied to every page you view in plspace. Overriding the custom properties in
- styles/tokens.css retints the entire app; the class hooks below let you go
- further.
+ Overriding the custom properties in styles/tokens.css retints the entire app;
+ the class hooks below let you go further.
Your CSS
@@ -174,32 +179,22 @@
- A profile can publish a stylesheet by putting CSS in a profile field named
- {#each CSS_FIELD_NAMES as name, index (name)}{name}{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}.
+ plspace publishes layouts in ordered fields named plspace:css1,
+ plspace:css2, and so on. Legacy
+ {#each CSS_FIELD_NAMES as name, index (name)}{name}{#if index < CSS_FIELD_NAMES.length - 1}, {/if}{/each}
+ fields are still understood.
Their rules are rewritten to apply only inside {PROFILE_SCOPE}, so a profile
can restyle its own page but not the rest of plspace.
-
+
- Add a profile field on {domain || 'your server'} named css 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
+ {domain || 'your server'} . Anyone who visits your profile with plspace
+ will receive it automatically.
- {#if session.signedIn}
-
-
- Edit your profile on {domain}
-
-
- {/if}
+
diff --git a/src/routes/Settings.test.ts b/src/routes/Settings.test.ts
new file mode 100644
index 0000000..b735e51
--- /dev/null
+++ b/src/routes/Settings.test.ts
@@ -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()
+ })
+})