commit: f127ae307b3a444f13c6f8b75ba99cf61244677e
parent 23f8c08809d2ad38780584ef4f46643772cf5efe
Author: HJ <30-hj@users.noreply.git.pleroma.social>
Date: Sat, 21 Sep 2024 08:18:23 +0000
Merge branch 'piss-serialization' into 'develop'
Pleroma ISS (interface stylesheets) implementation
See merge request pleroma/pleroma-fe!1943
Diffstat:
10 files changed, 252 insertions(+), 65 deletions(-)
diff --git a/changelog.d/piss-serialization.skip b/changelog.d/piss-serialization.skip
diff --git a/src/components/alert.style.js b/src/components/alert.style.js
@@ -27,7 +27,9 @@ export default {
component: 'Alert'
},
component: 'Border',
- textColor: '--parent'
+ directives: {
+ textColor: '--parent'
+ }
},
{
variant: 'error',
diff --git a/src/components/button.style.js b/src/components/button.style.js
@@ -34,8 +34,8 @@ export default {
directives: {
'--defaultButtonHoverGlow': 'shadow | 0 0 4 --text',
'--defaultButtonShadow': 'shadow | 0 0 2 #000000',
- '--defaultButtonBevel': 'shadow | $borderSide(#FFFFFF, top, 0.2) | $borderSide(#000000, bottom, 0.2)',
- '--pressedButtonBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2)| $borderSide(#000000, top, 0.2)'
+ '--defaultButtonBevel': 'shadow | $borderSide(#FFFFFF, top, 0.2), $borderSide(#000000, bottom, 0.2)',
+ '--pressedButtonBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2), $borderSide(#000000, top, 0.2)'
}
},
{
diff --git a/src/components/button_unstyled.style.js b/src/components/button_unstyled.style.js
@@ -16,8 +16,7 @@ export default {
{
directives: {
background: '#ffffff',
- opacity: 0,
- shadow: []
+ opacity: 0
}
},
{
diff --git a/src/components/input.style.js b/src/components/input.style.js
@@ -26,7 +26,7 @@ export default {
{
component: 'Root',
directives: {
- '--defaultInputBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2)| $borderSide(#000000, top, 0.2)'
+ '--defaultInputBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2), $borderSide(#000000, top, 0.2)'
}
},
{
diff --git a/src/components/panel_header.style.js b/src/components/panel_header.style.js
@@ -16,8 +16,7 @@ export default {
component: 'PanelHeader',
directives: {
backgroundNoCssColor: 'yes',
- background: '--fg',
- shadow: []
+ background: '--fg'
}
}
]
diff --git a/src/services/theme_data/iss_deserializer.js b/src/services/theme_data/iss_deserializer.js
@@ -0,0 +1,153 @@
+import { flattenDeep } from 'lodash'
+
+const parseShadow = string => {
+ const modes = ['_full', 'inset', 'x', 'y', 'blur', 'spread', 'color', 'alpha']
+ const regexPrep = [
+ // inset keyword (optional)
+ '^(?:(inset)\\s+)?',
+ // x
+ '(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)',
+ // y
+ '(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)',
+ // blur (optional)
+ '(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?',
+ // spread (optional)
+ '(?:(-?[0-9]+(?:\\.[0-9]+)?)\\s+)?',
+ // either hex, variable or function
+ '(#[0-9a-f]{6}|--[a-z\\-_]+|\\$[a-z\\-()_]+)',
+ // opacity (optional)
+ '(?:\\s+\\/\\s+([0-9]+(?:\\.[0-9]+)?)\\s*)?$'
+ ].join('')
+ const regex = new RegExp(regexPrep, 'gis') // global, (stable) indices, single-string
+ const result = regex.exec(string)
+ if (result == null) {
+ return string
+ } else {
+ const numeric = new Set(['x', 'y', 'blur', 'spread', 'alpha'])
+ const { x, y, blur, spread, alpha, inset, color } = Object.fromEntries(modes.map((mode, i) => {
+ if (numeric.has(mode)) {
+ return [mode, Number(result[i])]
+ } else if (mode === 'inset') {
+ return [mode, !!result[i]]
+ } else {
+ return [mode, result[i]]
+ }
+ }).filter(([k, v]) => v !== false).slice(1))
+
+ return { x, y, blur, spread, color, alpha, inset }
+ }
+}
+// this works nearly the same as HTML tree converter
+const parseIss = (input) => {
+ const buffer = [{ selector: null, content: [] }]
+ let textBuffer = ''
+
+ const getCurrentBuffer = () => {
+ let current = buffer[buffer.length - 1]
+ if (current == null) {
+ current = { selector: null, content: [] }
+ }
+ return current
+ }
+
+ // Processes current line buffer, adds it to output buffer and clears line buffer
+ const flushText = (kind) => {
+ if (textBuffer === '') return
+ if (kind === 'content') {
+ getCurrentBuffer().content.push(textBuffer.trim())
+ } else {
+ getCurrentBuffer().selector = textBuffer.trim()
+ }
+ textBuffer = ''
+ }
+
+ for (let i = 0; i < input.length; i++) {
+ const char = input[i]
+
+ if (char === ';') {
+ flushText('content')
+ } else if (char === '{') {
+ flushText('header')
+ } else if (char === '}') {
+ flushText('content')
+ buffer.push({ selector: null, content: [] })
+ textBuffer = ''
+ } else {
+ textBuffer += char
+ }
+ }
+
+ return buffer
+}
+export const deserialize = (input) => {
+ const ast = parseIss(input)
+ const finalResult = ast.filter(i => i.selector != null).map(item => {
+ const { selector, content } = item
+ let stateCount = 0
+ const selectors = selector.split(/,/g)
+ const result = selectors.map(selector => {
+ const output = { component: '' }
+ let currentDepth = null
+
+ selector.split(/ /g).reverse().forEach((fragment, index, arr) => {
+ const fragmentObject = { component: '' }
+
+ let mode = 'component'
+ for (let i = 0; i < fragment.length; i++) {
+ const char = fragment[i]
+ switch (char) {
+ case '.': {
+ mode = 'variant'
+ fragmentObject.variant = ''
+ break
+ }
+ case ':': {
+ mode = 'state'
+ fragmentObject.state = fragmentObject.state || []
+ stateCount++
+ break
+ }
+ default: {
+ if (mode === 'state') {
+ const currentState = fragmentObject.state[stateCount - 1]
+ if (currentState == null) {
+ fragmentObject.state.push('')
+ }
+ fragmentObject.state[stateCount - 1] += char
+ } else {
+ fragmentObject[mode] += char
+ }
+ }
+ }
+ }
+ if (currentDepth !== null) {
+ currentDepth.parent = { ...fragmentObject }
+ currentDepth = currentDepth.parent
+ } else {
+ Object.keys(fragmentObject).forEach(key => {
+ output[key] = fragmentObject[key]
+ })
+ if (index !== (arr.length - 1)) {
+ output.parent = { component: '' }
+ }
+ currentDepth = output
+ }
+ })
+
+ output.directives = Object.fromEntries(content.map(d => {
+ const [property, value] = d.split(':')
+ let realValue = value.trim()
+ if (property === 'shadow') {
+ realValue = value.split(',').map(v => parseShadow(v.trim()))
+ } if (!Number.isNaN(Number(value))) {
+ realValue = Number(value)
+ }
+ return [property, realValue]
+ }))
+
+ return output
+ })
+ return result
+ })
+ return flattenDeep(finalResult)
+}
diff --git a/src/services/theme_data/iss_serializer.js b/src/services/theme_data/iss_serializer.js
@@ -1,62 +1,44 @@
-import { unroll } from './iss_utils'
+import { unroll } from './iss_utils.js'
-const getCanonicState = (state) => {
- if (state) {
- return ['normal', ...state.filter(x => x !== 'normal')]
+const serializeShadow = s => {
+ if (typeof s === 'object') {
+ return `${s.inset ? 'inset ' : ''}${s.x} ${s.y} ${s.blur} ${s.spread} ${s.color} / ${s.alpha}`
} else {
- return ['normal']
+ return s
}
}
-const getCanonicRuleHeader = ({
- component,
- variant = 'normal',
- parent,
- state
-}) => ({
- component,
- variant,
- parent,
- state: getCanonicState(state)
-})
-
-const prepareRule = (rule) => {
- const { parent } = rule
- const chain = [...unroll(parent), rule].map(getCanonicRuleHeader)
- const header = chain.map(({ component, variant, state }) => [
- component,
- variant === 'normal' ? '' : ('.' + variant),
- state.filter(s => s !== 'normal').map(s => ':' + s).join('')
- ].join('')).join(' ')
-
- console.log(header, rule.directives)
- const content = Object.entries(rule.directives).map(([key, value]) => {
- let realValue = value
-
- switch (key) {
- case 'shadow':
- realValue = realValue.map(v => `${v.inset ? 'inset ' : ''}${v.x} ${v.y} ${v.blur} ${v.spread} ${v.color} / ${v.alpha}`)
- }
-
- if (Array.isArray(realValue)) {
- realValue = realValue.join(', ')
- }
-
- return ` ${key}: ${realValue};`
- }).sort().join('\n')
-
- return [
- header,
- content
- ]
-}
-
export const serialize = (ruleset) => {
- // Scrapped idea: automatically combine same-set directives
- // problem: might violate the order rules
-
- return ruleset.filter(r => Object.keys(r.directives).length > 0).map(r => {
- const [header, content] = prepareRule(r)
- return `${header} {\n${content}\n}\n\n`
- })
+ return ruleset.map((rule) => {
+ if (Object.keys(rule.directives || {}).length === 0) return false
+
+ const header = unroll(rule).reverse().map(rule => {
+ const { component } = rule
+ const newVariant = (rule.variant == null || rule.variant === 'normal') ? '' : ('.' + rule.variant)
+ const newState = (rule.state || []).filter(st => st !== 'normal')
+
+ return `${component}${newVariant}${newState.map(st => ':' + st).join('')}`
+ }).join(' ')
+
+ const content = Object.entries(rule.directives).map(([directive, value]) => {
+ if (directive.startsWith('--')) {
+ const [valType, newValue] = value.split('|') // only first one! intentional!
+ switch (valType) {
+ case 'shadow':
+ return ` ${directive}: ${valType.trim()} | ${newValue.map(serializeShadow).map(s => s.trim()).join(', ')}`
+ default:
+ return ` ${directive}: ${valType.trim()} | ${newValue.trim()}`
+ }
+ } else {
+ switch (directive) {
+ case 'shadow':
+ return ` ${directive}: ${value.map(serializeShadow).join(', ')}`
+ default:
+ return ` ${directive}: ${value}`
+ }
+ }
+ })
+
+ return `${header} {\n${content.join(';\n')}\n}`
+ }).filter(x => x).join('\n\n')
}
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
@@ -504,9 +504,21 @@ export const init = ({
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
}
+ // optimization to traverse big-ass array only once instead of twice
+ const eager = []
+ const lazy = []
+
+ result.forEach(x => {
+ if (typeof x === 'function') {
+ lazy.push(x)
+ } else {
+ eager.push(x)
+ }
+ })
+
return {
- lazy: result.filter(x => typeof x === 'function'),
- eager: result.filter(x => typeof x !== 'function'),
+ lazy,
+ eager,
staticVars,
engineChecksum
}
diff --git a/test/unit/specs/services/theme_data/iss_deserializer.spec.js b/test/unit/specs/services/theme_data/iss_deserializer.spec.js
@@ -0,0 +1,40 @@
+import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
+import { serialize } from 'src/services/theme_data/iss_serializer.js'
+const componentsContext = require.context('src', true, /\.style.js(on)?$/)
+
+describe('ISS (de)serialization', () => {
+ componentsContext.keys().forEach(key => {
+ const component = componentsContext(key).default
+
+ it(`(De)serialization of component ${component.name} works`, () => {
+ const normalized = component.defaultRules.map(x => ({ component: component.name, ...x }))
+ const serialized = serialize(normalized)
+ const deserialized = deserialize(serialized)
+
+ // for some reason comparing objects directly fails the assert
+ expect(JSON.stringify(deserialized, null, 2)).to.equal(JSON.stringify(normalized, null, 2))
+ })
+ })
+
+ /*
+ // Debug snippet
+ const onlyComponent = componentsContext('./components/panel_header.style.js').default
+ it(`(De)serialization of component ${onlyComponent.name} works`, () => {
+ const normalized = onlyComponent.defaultRules.map(x => ({ component: onlyComponent.name, ...x }))
+ console.log('BEGIN INPUT ================')
+ console.log(normalized)
+ console.log('END INPUT ==================')
+ const serialized = serialize(normalized)
+ console.log('BEGIN SERIAL ===============')
+ console.log(serialized)
+ console.log('END SERIAL =================')
+ const deserialized = deserialize(serialized)
+ console.log('BEGIN DESERIALIZED =========')
+ console.log(serialized)
+ console.log('END DESERIALIZED ===========')
+
+ // for some reason comparing objects directly fails the assert
+ expect(JSON.stringify(deserialized, null, 2)).to.equal(JSON.stringify(normalized, null, 2))
+ })
+ */
+})