From 81176654ba088f884132697d0c229fbd7e41260f Mon Sep 17 00:00:00 2001 From: "Moon.eth" Date: Wed, 29 Jul 2026 16:03:12 +0900 Subject: [PATCH] custom css works now --- .../profile/PublishedCssEditor.svelte | 158 ++++++++++++ .../profile/PublishedCssEditor.test.ts | 102 ++++++++ src/lib/api/endpoints.test.ts | 40 +++ src/lib/api/endpoints.ts | 21 ++ src/lib/api/types.ts | 23 +- src/lib/stores/theme.svelte.ts | 236 +++++++++++++++++- src/lib/stores/theme.test.ts | 144 +++++++++++ src/lib/util/profile.test.ts | 21 ++ src/lib/util/profile.ts | 4 + src/routes/Settings.svelte | 41 ++- src/routes/Settings.test.ts | 22 ++ 11 files changed, 777 insertions(+), 35 deletions(-) create mode 100644 src/components/profile/PublishedCssEditor.svelte create mode 100644 src/components/profile/PublishedCssEditor.test.ts create mode 100644 src/lib/api/endpoints.test.ts create mode 100644 src/lib/stores/theme.test.ts create mode 100644 src/lib/util/profile.test.ts create mode 100644 src/routes/Settings.test.ts 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} + + +

+ {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} + + {/if} + +
+ + +
+ {: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} + + {:else if saved} +

Your public profile CSS was updated.

+ {/if} + +
+ + {#if currentCss} + + {/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.

@@ -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() + }) +})