webapps/packages/layers/content/utils/content-canonical.ts
Dominik Milacher 73083ded58
Some checks failed
Build and deploy updated apps / Build & deploy (push) Failing after 1m7s
Overhaul content management
2025-10-22 19:31:38 +02:00

382 lines
10 KiB
TypeScript

export type Primitive = string | number | boolean | null
export type JsonObject = { [key: string]: JsonValue }
export type JsonArray = JsonValue[]
export type JsonValue = Primitive | JsonObject | JsonArray
export type CanonicalValue = CanonicalPrimitive | CanonicalObject | CanonicalArray
// We do not use Map or Set as properties, they cant be serialized to JSON properly
// Instead we use objects and arrays under the hood, and optimize speed/memory for runtime, not build time
// TODO: In fromCanonicalJson the .x are not set (not critical actually)
export class CanonicalBase {
protected readonly x: number = 0
private readonly t?: string[]
constructor(tags: string[]) {
const clean = cleanTags(tags)
this.t = clean.length ? clean : undefined
}
get tags(): readonly string[] {
return this.t ?? []
}
get delete(): boolean {
return this.t?.includes('delete') ?? false
}
get replace(): boolean {
return this.t?.includes('replace') ?? false
}
static fromCanonicalJson(json: JsonObject): CanonicalBase {
const base = Object.create(CanonicalBase.prototype)
base.t = json.t
return base as CanonicalBase
}
mergeInto(other: CanonicalBase): CanonicalBase {
throw new Error('Not implemented')
}
dropTags(except: string[]): CanonicalBase {
throw new Error('Not implemented')
}
reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any {
throw new Error('Not implemented')
}
}
export class CanonicalPrimitive extends CanonicalBase {
protected override readonly x: number = 1
private v: Primitive | string[]
private e?: boolean
get value(): Readonly<Primitive | string[]> {
return this.v
}
get expand(): boolean {
return this.e ?? false
}
constructor(tags: string[], value: Primitive | string[], expand?: boolean) {
super(tags)
this.v = value
this.e = expand
}
static fromInputJson(outerTags: string[], primitive: Primitive): CanonicalPrimitive {
let v: Primitive | string[]
let e: boolean | undefined = undefined
if (typeof primitive === 'string') {
const segments = primitive.split(/\$\{(.*?)\}/)
if (segments.length > 1) {
v = segments
e = true
} else {
v = primitive
}
} else {
v = primitive
}
return new CanonicalPrimitive(outerTags, v, e)
}
static override fromCanonicalJson(json: JsonObject): CanonicalPrimitive {
const primitive = super.fromCanonicalJson(json) as CanonicalPrimitive
Object.setPrototypeOf(primitive, CanonicalPrimitive.prototype)
primitive.v = json.v as Primitive | string[]
primitive.e = json.e as boolean | undefined
return primitive
}
override mergeInto(other: CanonicalPrimitive): CanonicalPrimitive {
return this
}
override dropTags(except: string[]): CanonicalPrimitive {
return new CanonicalPrimitive(this.tags.filter(t => except.includes(t)), this.v, this.e)
}
override reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): CanonicalPrimitive | Primitive | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
if (this.expand) {
options.expand = true
return this
}
return this.v as Primitive
}
resolve(resolver: (key: string) => JsonValue): string {
return (this.v as string[]).map((p, i) => i % 2 ? resolver(p) : p).join('')
}
}
export class CanonicalObject extends CanonicalBase {
protected override readonly x: number = 2
private n?: Record<string, CanonicalValue>
private a?: CanonicalValue[]
constructor(
tags: string[],
named: Record<string, CanonicalValue>,
anonymous: CanonicalValue[],
) {
super(tags)
if (Object.keys(named).length) {
this.n = named
}
if (anonymous.length) {
this.a = anonymous
}
}
get named(): Readonly<Record<string, CanonicalValue>> {
return this.n ?? {}
}
get anonymous(): readonly CanonicalValue[] {
return this.a ?? []
}
static fromInputJson(outerTags: string[], json: JsonObject): CanonicalObject {
let n: Record<string, CanonicalValue> = {}
let a: CanonicalValue[] = []
for (const key in json) {
if (key === '$tags') continue
let [name, ...tags] = key.split('$')
tags = cleanTags(tags)
if (name) {
n[name] = fromInputJson(tags, json[key] as JsonValue)
} else if (tags.length) {
a.push(fromInputJson(tags, json[key] as JsonValue))
} else {
console.error(`No name and no tags found in key '${key}'`)
}
}
return new CanonicalObject([...outerTags, ...getInnerTags(json)], n, a)
}
static override fromCanonicalJson(json: JsonObject): CanonicalObject {
const object = super.fromCanonicalJson(json) as CanonicalObject
Object.setPrototypeOf(object, CanonicalObject.prototype)
if (json.n) {
object.n = {}
for (const [k, v] of Object.entries(json.n)) {
object.n[k] = fromCanonicalJson(v)
}
}
if (json.a) {
object.a = (json.a as JsonValue[]).map(i => fromCanonicalJson(i))
}
return object
}
override mergeInto(other: CanonicalObject): CanonicalObject {
const n: Record<string, CanonicalValue> = {}
for (const key of new Set([...Object.keys(this.named), ...Object.keys(other.named)])) {
let merged: CanonicalValue | undefined = undefined
if (key in this.named && key in other.named) {
merged = mergeInto(this.named[key]!, other.named[key]!)
} else {
merged = key in this.named ? this.named[key] : other.named[key]
}
if (merged) {
n[key] = merged
}
}
return new CanonicalObject([...this.tags], n, [...this.anonymous])
}
override dropTags(except: string[]): CanonicalObject {
const n = Object.fromEntries(Object.entries(this.named).map(([k, v]) => [k, v.dropTags(except)]))
const a = this.anonymous.map(a => a.dropTags(except))
return new CanonicalObject(this.tags.filter(t => except.includes(t)), n, a)
}
override reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
const n = Object.fromEntries(Object.entries(this.named).map(([k, v]) => [k, v.reduce(options)]).filter(([k, v]) => v !== undefined))
if (Object.keys(n).length) {
return n
}
const a = this.anonymous.map(a => a.reduce(options)).filter(a => a !== undefined)
if (a.length) {
if (a.length > 1) {
console.error(`Multiple anonymous object properties: ${JSON.stringify(a)}`)
}
return a[0]
}
return undefined
}
}
export class CanonicalArray extends CanonicalBase {
protected override readonly x: number = 3
private i?: CanonicalValue[]
constructor(tags: string[], items: CanonicalValue[]) {
super(tags)
if (items.length) {
this.i = items
}
}
get items(): readonly CanonicalValue[] {
return this.i ?? []
}
static fromInputJson(outerTags: string[], json: JsonArray): CanonicalArray {
const tags = [...outerTags]
json = json.filter(item => {
if (typeof item === 'object' && item !== null && Object.keys(item).length === 1 && '$tags' in item) {
tags.push(...getInnerTags(item))
return false
}
return true
})
return new CanonicalArray(tags, json.map(i => fromInputJson([], i)))
}
static override fromCanonicalJson(json: JsonObject): CanonicalArray {
const array = super.fromCanonicalJson(json) as CanonicalArray
Object.setPrototypeOf(array, CanonicalArray.prototype)
if (json.i) {
array.i = (json.i as JsonValue[]).map(i => fromCanonicalJson(i))
}
return array
}
override mergeInto(other: CanonicalArray): CanonicalArray {
console.error("Merging arrays not yet implemented")
return this // TODO
}
override dropTags(except: string[]): CanonicalArray {
const items = (this.i ?? []).map(i => i.dropTags(except))
return new CanonicalArray(this.tags.filter(t => except.includes(t)), items)
}
override reduce(options: { predicate: (tags?: string[]) => boolean; expand?: boolean }): any | undefined {
if (!options.predicate(this.tags)) {
return undefined
}
return this.items.map(i => i.reduce(options)).filter(i => i !== undefined)
}
}
export function fromInputJson(outerTags: string[], json: JsonValue): CanonicalValue {
if (Array.isArray(json)) {
return CanonicalArray.fromInputJson(outerTags, json)
} else if (typeof json === 'object' && json !== null) {
return CanonicalObject.fromInputJson(outerTags, json)
}
return CanonicalPrimitive.fromInputJson(outerTags, json)
}
export function fromCanonicalJson(json: JsonValue): CanonicalValue {
if (typeof json === 'object' && json !== null) {
json = json as JsonObject
if (json.x === 1) {
return CanonicalPrimitive.fromCanonicalJson(json)
} else if (json.x == 2) {
return CanonicalObject.fromCanonicalJson(json)
}
return CanonicalArray.fromCanonicalJson(json)
}
throw new Error(`Not a valid canonical object: ${json}`)
}
function mergeInto(self: CanonicalValue, other: CanonicalValue): CanonicalValue | undefined {
if (self instanceof CanonicalBase) {
if (self.delete) {
return undefined
}
if (self.replace) {
return self
}
}
if (self instanceof CanonicalPrimitive && other instanceof CanonicalPrimitive
|| self instanceof CanonicalObject && other instanceof CanonicalObject
|| self instanceof CanonicalArray && other instanceof CanonicalArray) {
return self.mergeInto(other)
}
return self
}
export function merge(items: CanonicalValue[]): CanonicalValue | undefined {
if (!items.length) {
return undefined
} else if (items.length === 1) {
return items[0]
}
return items.reduceRight((acc, cur) => mergeInto(cur, acc))
}
function getInnerTags(json: JsonObject): string[] {
const tags = json['$tags']
if (typeof tags === 'string') {
return cleanTags(tags.split(' '))
}
return []
}
function cleanTags(tags: string[]): string[] {
return [...new Set(tags)].map(t => t.trim()).filter(t => !!t).sort()
}