import mudder from 'mudder' import qs from 'query-string' import sanitizeHtml from 'sanitize-html' import SDKAdapter from '../core/adapter.js' import { SDKCollection } from '../core/collection.js' import { SDKModel } from '../core/model.js' import { Atleast, Optional, RequiredKeys } from '../utils/typescript.js' import { SDKModelSchema } from '../core/schema.js' import { nanoid } from '../core/transport.js' import versionSchema from '../schemas/version.json' assert { type: 'json' } import type { SDK } from '../sdk.js' import * as Style from '../style/index.js' import { isDeepEquals } from '../utils/object.js' import type { CollectionModel } from './collections.js' import type { ComponentModel } from './components.js' import type { LibraryModel, NanoId } from './libraries.js' import { DataOptionsById, DatasourceModel } from './datasources.js' import type { VersionBundle } from './version-bundles.js' /** Defines the schema for Version. */ export interface Version { /** The identifier of the Library that Version belongs to */ libraryId: NanoId /** The reference identifer of the Version */ id: NanoId /** The identifier of the Component that Version belongs to */ componentId: NanoId /** * The name of the Version. * * @minLength 1 * @maxLength 128 */ name: string /** Textual description of the Version. */ description?: string /** The status of the Version */ status: 'published' | 'draft' | 'staged' | 'saved' /** * The order index of the Version. * * @minLength 1 * @maxLength 64 */ orderIdx?: string /** * The numerc revision of the Version. * * @minimum 0 * @TJS-type integer */ revision: number /** The model presentation of the Version */ model?: string /** * The view presentation of the Version. * * @minLength 1 */ view: string /** The difference between current and previous version of the Version */ diff?: object /** * List of ids of applicable breakpoints. Null if all breakpoints are applicable. * * @nullable */ breakpointIds?: string[] /** * The identifiers of the datasources for this Version. * * @items.type string */ datasourceIds?: string[] /** Allow version to be embeddable on its own, not as a part of responsive bundle */ isEmbeddable?: boolean /** * Id of a version that was used as a base for the fork. * * @nullable */ forkOriginId?: NanoId /** * Revision of a version that was used as a base for the fork. * * @nullable * @minimum 0 * @TJS-type integer */ forkOriginRevision?: number /** * Time when the version was first forked. * * @nullable */ forkedAt?: Date /** * Array of extra css classes applied to the version. * * @nullable */ classList?: string[] /** The timestamp when the Version was created at */ createdAt?: Date /** * The timestamp when the Version was deleted at. * * @nullable */ deletedAt?: Date /** The timestamp when the Version was last modified at */ modifiedAt?: Date /** The User who last modified the Version */ modifiedBy?: object } export interface VersionSnapshot { id: VersionModel['id'] head?: VersionModel previous?: VersionModel draft?: VersionModel saved?: VersionModel staged?: VersionModel published?: VersionModel revisions: VersionModel[] } const allowedTags = [ ...sanitizeHtml.defaults.allowedTags, 'button', 'picture', 'img', 'svg', 'g', 'path', 'defs', 'linearGradient', 'stop', 'circle', 'style', 'feaas-component', 'feaas-external', 'feaas-stylesheet' ] const CustomAttributes = [ 'data', 'data-attributes', 'data-embed-html', 'data-embed-title', 'data-embed-src', 'data-embed-as', 'data-external-id', 'component', 'cdn', 'version', 'revision', 'hostname', 'hidden' ] const nonBooleanAttributes = [...sanitizeHtml.defaults.nonBooleanAttributes, ...CustomAttributes] const allowedAttributes = { ...sanitizeHtml.defaults.allowedAttributes, '*': ['class', 'style', 'id', 'data-*', ...CustomAttributes], path: ['d', 'fill', 'fill-rule', 'clip-rule'], 'feaas-external': ['*'], 'feaas-component': ['*'], 'feaas-stylesheet': ['*'] } export class Versions extends SDKAdapter { search(version: Pick, query?: any): Promise { const { libraryId } = version return this.sdk.fetchJSON(`/libraries/${libraryId}/versions?${qs.stringify(query)}`) } get( version: Pick ): Promise { const { libraryId, collectionId, componentId, id, status, revision } = version return this.sdk.fetchJSON( `/libraries/${libraryId}/collections/${ collectionId || 'unspecified' }/components/${componentId}/versions/${id}?status=${status}&revision=${revision}` ) } fetch(version: Pick): Promise { const { libraryId, collectionId, componentId } = version return this.sdk.fetchJSON( `/libraries/${libraryId}/collections/${collectionId || 'unspecified'}/components/${componentId}/versions?` + Math.random() ) } post( version: Optional & Atleast ) { const { libraryId, collectionId, componentId } = version return this.sdk.fetchJSON( `/libraries/${libraryId}/collections/${collectionId || 'unspecified'}/components/${componentId}/versions`, { method: 'POST', body: JSON.stringify(version) } ) } put( version: Optional & Atleast ) { return this.post(version) } delete(version: Atleast) { const { libraryId, collectionId, componentId, id, status, revision } = version return this.sdk.fetchJSON( `/libraries/${libraryId}/collections/${ collectionId || 'unspecified' }/components/${componentId}/versions/${id}?status=${status}&revision=${revision}`, { method: 'DELETE', headers: { 'Content-Type': 'text/plain' } } ) } prune({ libraryId, componentId, id }: Partial> = {}) { // not implemented on clientside } } function getVersionDefaults(sdk: SDK) { const now = new Date() return { id: nanoid(10), orderIdx: '', revision: 0, createdAt: now, modifiedAt: now, deletedAt: null as Date, modifiedBy: sdk?.auth?.getUser() || {}, datasourceIds: [] as Style.RuleId[], breakpointIds: null as Style.RuleId[], classList: ['-theme--default'] as string[], view: '
', model: '', diff: {}, status: 'draft' } } export type VersionMinimal = RequiredKeys export interface VersionImplicit { library?: LibraryModel libraryId: LibraryModel['id'] collectionId?: CollectionModel['id'] collection?: CollectionModel componentId: ComponentModel['id'] component?: ComponentModel bundle?: VersionBundle bundleId?: VersionBundle['id'] } export interface VersionParams extends Version, VersionImplicit {} export interface VersionModel extends VersionParams {} export class VersionModel extends SDKModel implements VersionParams { error?: Error get adapter() { return this.sdk.Versions } getId() { return this.componentId + ':' + this.id + ':' + this.revision + ':' + this.status } getHiddenProps(): string[] { return ['library', 'collectionId', 'collection', 'component'] } getDefaults(sdk: SDK) { return getVersionDefaults(sdk) } getThumbnailURL() { return `${this.sdk.cdn}/thumbnails/${this.libraryId}/${this.componentId}/${this.id}.jpg` } defineProperties() { super.defineProperties() this.sdk.utils.defineAccessor(this, 'view', {}, this.getHiddenProps()) } /** View needs to be wrapped into
*/ _view: string get view() { return this._view } isViewEmpty() { return this.view.match(/^]*><\/section>$/i) } /** Normalizes html, extracts used datasources */ set view(value: string) { if (typeof value == 'string') { value = this.normalizeView(value) this.datasourceIds = this.getDatasourcesFromView(value) } this._view = value } /** Extract all datasource ids from view used in data-path-* attributes */ getDatasourcesFromView(view: string) { const set = new Set() view.replace(/(data-path[a-z-]*)="([a-z0-9-_]+)[^"]*"/gi, (m, attribute, datasourceId) => { if (attribute != 'data-path-placeholder') set.add(datasourceId) return m }) return Array.from(set) .filter((v) => Boolean(v)) .sort() } /** * Transform view minimally to help reduce amount of revisions. It tries to bring HTML to a canonical version, so that * things like different order of classes will not be considered a different version */ normalizeView(view: string) { // ensure there's wrapping section element if (!view.match(/^[\s\n]*(${view}
` } return this.sdk.normalizeHTML(view) } /** Combine view, classes and themes */ getHTML(extraClasses: string[] = []) { return `
${this.view}
` } /** Get latest revisions of a version matching given statuses (or first) */ getCurrent(statuses?: Version['status'][] | Version['status']) { return this.component.findVersion(this.id, [statuses || []].flat()) } /** Get settings for all datasoruces used in version */ getDataSettings(): DataOptionsById | null { return DatasourceModel.getSettingsFromDatasources(this.datasourceIds, this.sdk.datasources) } /** Get current draft or create a new one */ getDraft() { const current = this.getCurrent() if (current.status == 'draft') { return current } else { return current.produceNewVersion({ ...current, modifiedBy: this.sdk.auth.getUser() || current.modifiedBy, modifiedAt: new Date(), revision: current.revision + 1, deletedAt: null, status: 'draft' }) } } /** * Use current revision as a basis to create an entirely new one-off version. Used for for in-place editing of * components. */ fork(id = this.sdk.nanoid()) { if (this.forkOriginId) { return this } return this.produceNewVersion({ id: id, forkedAt: new Date(), forkOriginId: this.id, forkOriginRevision: this.revision, modifiedBy: this.sdk.auth.getUser() }) } /** Return to the shared version of a component. The fork will be marked as deleted. */ unfork() { if (!this.forkOriginId) { return this } return this.archive() } /** Create new version based on the current with new ID */ produceNewVersion(data?: Partial) { const { modifiedBy, ...rest } = this return this.component.versions.add({ ...rest, ...data }) } /** * Creates new instance of a version revision if any data has changed. * * ! Replaces it in the collection to notify observers */ changeAndNotify(data: Partial) { var start = data.view == null || this.compareProp('view', data.view, this.view) ? this : this.getDraft() const changed = start.change(data) if (changed != start) { this.component.versions.upsertItem(changed) } return changed } /** Move version after another matching given id (or to the top if null) */ move(anchorId: Version['id'], position: 'before' | 'after' = 'before') { if (!anchorId) { const versions = this.component.getVersions() if (position == 'after') { anchorId = versions[0]?.id position = 'before' } else { anchorId = versions[versions.length - 1]?.id } } const previous = this.component.findVersion(anchorId) const neighbours = this.component.getNeighbourVersions(anchorId) const orderIdx = position == 'before' ? VersionModel.getVersionOrderIdx(neighbours[0], previous) : VersionModel.getVersionOrderIdx(previous, neighbours[1]) return this.getCurrent().changeAndNotify({ orderIdx }) } /** Soft-delete saved version, discards draft */ archive() { const draft = this.getCurrent('draft') const saved = this.getCurrent('saved') if (draft && (!saved || draft.revision > saved.revision)) { draft.discard() } if (saved) { return saved.changeAndNotify({ deletedAt: new Date() }) } return this } compareProp(property: keyof this, left: any, right: any) { if (property == 'view' && left != null && right != null) { return this.normalizeView(left) == this.normalizeView(right) } return super.compareProp(property, left, right) } /** Soft-undelete version */ restore() { const saved = this.getCurrent('saved') || this.getCurrent('draft') return saved.changeAndNotify({ deletedAt: null }) } /** Set version size breakpoints and commit as new revision */ setBreakpointIds(breakpointIds: Version['breakpointIds']) { this.getDraft().changeAndNotify({ breakpointIds }) } /** Creates new version with provided combo */ setClassList(classList: string[]) { return this.getDraft().changeAndNotify({ classList: classList }) } /** Set current view/model data to version. Ensures that there is a draft, in case given data has changes */ commitData({ view, model }: { view: Version['view']; model: Version['model'] }) { if (this.error) return const last = this.getCurrent() if (last.view != view || last.model != model) { return this.getDraft().changeAndNotify({ modifiedBy: this.sdk.auth.getUser(), modifiedAt: new Date(), view, model }) } return this } /** Commits given data a new saved revision if there were an changes */ saveData(data?: Atleast) { if (this.error) return const last = this.getCurrent() if (last && last.status == 'draft' && last.deletedAt != null) { return } if (!data) data = last const { view: dirtyView, model } = data // Pre-process: temporarily replace empty href with a placeholder const preprocessed = dirtyView.replace(/href=""/g, 'href="__EMPTY_HREF__"') const cleanView = sanitizeHtml(preprocessed, { allowedTags, nonBooleanAttributes, allowedAttributes, allowVulnerableTags: true // allow