/**
* @fileoverview Handle backward compatibility when a user opens a site for edition
*
*/
import * as Path from 'path'
import * as fs from 'fs'
import { Constants } from '../../constants'
import {
ElementData,
ElementState,
ElementType,
Link,
LinkType
} from '../../client/element-store/types'
import { PageData } from '../../client/page-store/types'
import { PersistantData } from '../../client/store/types'
import {
cleanupBefore,
getElementsFromDomBC,
getPagesFromDom,
getSiteFromDom,
writeSiteStyles,
writeStyles
} from './BackwardCompatV2.5.60'
import { getDomElement } from '../../client/element-store/dom'
import { setTagName } from '../../client/utils/dom'
import { writeDataToDom } from '../../client/store/dom'
/**
* util function for the "fixes", @see fixes
*/
function updateLinks(items: T[]): T[] {
return items
.map((item: T) => {
if(!!item.link && !item.link.linkType) {
// add new props
const link: any = {
...item.link,
linkType: item.link.type as LinkType,
href: (item.link as any).value,
}
// remove old props
delete link.type
delete link.value
// return the updated element
return {
...item,
link,
}
} else {
return item
}
})
}
/**
* class name for containers which are created with sections
*/
const SECTION_CONTAINER = 'silex-container-content'
export default class BackwardCompat {
private data: PersistantData = null
private frontEndVersion: string[]
private silexVersion: number[]
constructor(private rootUrl: string, rootPath = __dirname + '/../../../..') {
/**
* the version of the website is stored in the generator tag as "Silex v-X-Y-Z"
* we get it from package.json
* used for backward compat and for the static files URLs taken from //{{host}}/static/{{Y-Z}}
*/
const packageJson = JSON.parse(fs.readFileSync(Path.resolve(rootPath, 'package.json')).toString())
this.frontEndVersion = packageJson['version:frontend'].split('.').map((s) => parseInt(s))
this.silexVersion = packageJson['version:backwardcompat'].split('.').map((s) => parseInt(s))
// const components = require('../../../dist/client/libs/prodotype/components/components.json')
console.log(`\nStarting. Version is ${this.silexVersion} for the websites and ${this.frontEndVersion} for Silex\n`)
}
// remove all tags
// export for tests
removeIfExist(doc: HTMLDocument, selector: string) {
Array.from(doc.querySelectorAll(selector))
.forEach((tag) => tag.remove())
}
// remove all useless css class
// export for tests
removeUselessCSSClass(doc: HTMLDocument, className: string) {
Array.from(doc.querySelectorAll('.' + className))
.forEach((el) => el.classList.remove(className))
}
getVersion(doc): number[] {
// if no generator tag, create one
let metaNode = doc.querySelector('meta[name="generator"]')
if (!metaNode) {
metaNode = doc.createElement('meta')
metaNode.setAttribute('name', 'generator')
doc.head.appendChild(metaNode)
}
// retrieve the website version from generator tag
return (metaNode.getAttribute('content') || '')
.replace('Silex v', '')
.split('.')
.map((str) => parseInt(str, 10) || 0)
}
hasDataFile(doc): boolean {
return !this.hasToUpdate(this.getVersion(doc), [2, 2, 11])
}
/**
* handle backward compatibility issues
* Backwardcompatibility process takes place after opening a file
* @param {Document} doc
* @return {Promise} a Promise, resolve can be called with a warning message
*/
async update(doc: HTMLDocument, data: PersistantData): Promise<[string, PersistantData]> {
// // fix an issue when the style tag has no type, then json is "broken"
// const styleTag = doc.querySelector('.' + Constants.JSON_STYLE_TAG_CLASS_NAME);
// if (styleTag) { styleTag.type = 'text/json'; } // old versions of silex have no json at all so do nothing in that case
// TODO: move this to the data model (e.g. data.site.silexVersion)
// we need this.data as to2_2_11 will extract it and set it from the dom
this.data = data
const version = this.getVersion(doc)
const hasToUpdate = this.hasToUpdate(version, this.silexVersion)
// warn the user
if (this.amIObsolete(version, this.silexVersion)) {
return ['This website has been saved with a newer version of Silex. Continue at your own risks.', this.data]
} else if (this.hasToUpdate(version, [2, 2, 7])) {
return Promise.reject({
message: 'This website has been saved with an older version of Silex, which is not supported anymore as of March 2018. In order to convert it to a newer version, please go to old.silex.me to open and then save your website. More about this here',
})
} else if (hasToUpdate) {
// convert to the latest version
const allActions = {
'2.2.8': await this.to2_2_8(version, doc),
'2.2.9': await this.to2_2_9(version, doc),
'2.2.10': await this.to2_2_10(version, doc),
'2.2.11': await this.to2_2_11(version, doc), // this will set this.data
'2.2.12': await this.to2_2_12(version, doc),
'2.2.13': await this.to2_2_13(version, doc),
'2.2.14': await this.to2_2_14(version, doc),
}
// update the static scripts to match the current server and latest version
this.updateStatic(doc)
// store the latest version
const metaNode = doc.querySelector('meta[name="generator"]')
metaNode.setAttribute('content', 'Silex v' + this.silexVersion.join('.'))
// apply all-time fixes
this.fixes(doc)
// build the report for the user
const report = Object.keys(allActions)
.filter((_version) => allActions[_version].length > 0)
.map((_version) => {
return `Update to version ${ _version }:
${ allActions[_version].map((_action) => `- ${ _action }
`).join('') }
`
}).join('')
// save data to dom for front-end.js and other scripts
// in case data has been changed
// FIXME: should not have this.data mutated but returned by update scripts
writeDataToDom(doc, this.data)
// needs to reload if silex scripts and stylesheets have been updated
return [`
This website has been updated to Silex latest version.
Before you save it, please check that everything is fine. Saving it with another name could be a good idea too (menu file > save as).
Details
${ report }
`,
this.data,
]
} else {
// update the static scripts to match the current server URL
this.updateStatic(doc)
// apply all-time fixes
this.fixes(doc)
// resolve immediately
return ['', this.data]
}
}
/**
* Check for common errors in editable html files
*/
fixes(doc: HTMLDocument) {
// const pages: HTMLElement[] = Array.from(doc.querySelectorAll(`.${Constants.PAGES_CONTAINER_CLASS_NAME} a[${Constants.TYPE_ATTR}="page"]`));
// if (pages.length > 0) {
// console.log('Fix error of wrong silex type for', pages.length, 'pages');
// pages.forEach((page) => page.setAttribute(Constants.TYPE_ATTR, Constants.TYPE_PAGE));
// }
// the following is a fix following the beta version of 07-2020
// for elements and pages:
// link.value becomes href
// link.type becomes linkType
this.data = {
site: this.data.site,
pages: updateLinks(this.data.pages),
elements: updateLinks(this.data.elements),
}
// remove juery-ui at publication, in case the website has been updated before the fix of 2.6.2
Array.from(doc.querySelectorAll('script[src$="pageable.js"], script[src$="jquery-ui.js"]'))
.forEach((tag) => tag.setAttribute(Constants.ATTR_REMOVE_PUBLISH, ''))
// this does not work because sections can not be smaller than their content:
// // resizable sections
// this.data = {
// ...this.data,
// elements: this.data.elements
// .map((el) => el.type === ElementType.SECTION ? {
// ...el,
// enableResize: {
// bottom: true,
// top: true,
// left: false,
// right: false,
// }
// } : el),
// }
// remove pages which do not exist (was caused by a bug in deletePages, fix 2021-03)
// also remove css classes which are pages (was caused by a bug??)
this.data.elements = this.data.elements.map(el => ({
...el,
pageNames: el.pageNames.filter(name => this.data.pages.some(p => p.id === name)),
classList: el.classList.filter(name => !this.data.pages.some(p => p.id === name)),
}))
}
/**
* update the static scripts to match the current server and latest version
*/
updateStatic(doc: HTMLDocument) {
// update //{{host}}/2.x/... to latest version
Array.from(doc.querySelectorAll('[' + Constants.STATIC_ASSET_ATTR + ']'))
.forEach((element: HTMLElement) => {
const propName = element.hasAttribute('src') ? 'src' : 'href'
if (element.hasAttribute(propName)) {
const newUrl = this.getStaticResourceUrl(element[propName])
const oldUrl = element.getAttribute(propName)
if (oldUrl !== newUrl) {
element.setAttribute(propName, newUrl)
}
}
})
}
/**
* get the complete URL for the static file,
* * on the current Silex server
* * with the latest Silex version
*
* this will result in a URL on the current server, in the `/static/` folder
*
* @example `//localhost:6805/static/2.1/example/example.js` returns `//editor.silex.me/static/2.7/example/unslider.js`
* @example `/static/2.1/example/example.js` returns `//editor.silex.me/static/2.7/example/example.js`
*
* with the current version
* @param {string} url
* @return {string}
*/
getStaticResourceUrl(url: string): string {
const pathRelativeToStaticMatch = url.match(/static\/[0-9]*\.[0-9]*\/(.*)/)
if (pathRelativeToStaticMatch == null) {
console.warn('Error: could not extract the path and file name of static asset', url)
return url
}
const pathRelativeToStatic = pathRelativeToStaticMatch[1]
return `${ this.rootUrl }/static/${ this.frontEndVersion[0] }.${ this.frontEndVersion[1] }/${ pathRelativeToStatic }`
}
/**
* check if the website has been edited with a newer version of Silex
* @param {Array.} initialVersion the website version
* @param {Array.} targetVersion a given Silex version
* @return {boolean}
*/
amIObsolete(initialVersion: number[], targetVersion: number[]): boolean {
return !!initialVersion[2] && initialVersion[0] > targetVersion[0] ||
initialVersion[1] > targetVersion[1] ||
initialVersion[2] > targetVersion[2]
}
/**
* check if the website has to be updated for the given version of Silex
* @param {Array.} initialVersion the website version
* @param {Array.} targetVersion a given Silex version
* @return {boolean}
*/
hasToUpdate(initialVersion: number[], targetVersion: number[]): boolean {
return initialVersion[0] < targetVersion[0] ||
initialVersion[1] < targetVersion[1] ||
initialVersion[2] < targetVersion[2]
}
to2_2_8(version: number[], doc: HTMLDocument): Promise {
return new Promise((resolve, reject) => {
const actions = []
if (this.hasToUpdate(version, [2, 2, 8])) {
// cleanup the hamburger menu icon
const menuButton = doc.querySelector('.menu-button')
if (menuButton) {
menuButton.classList.remove('paged-element', 'paged-element-hidden', 'page-page-1', 'prevent-resizable')
menuButton.classList.add('hide-on-desktop')
}
// give the hamburger menu a size (TODO: add to the json model too)
doc.querySelector('.silex-inline-styles').innerHTML += '.silex-id-hamburger-menu {width: 50px;min-height: 40px;}'
// pages need to have href set
Array.from(doc.querySelectorAll('.page-element'))
.forEach((el: HTMLLinkElement) => {
el.setAttribute('href', '#!' + el.getAttribute('id'))
})
actions.push('I fixed the mobile menu so that it is compatible with the new publication (now multiple pages are generated instead of 1 single page for the whole website).')
}
resolve(actions)
})
}
to2_2_9(version: number[], doc: HTMLDocument): Promise {
return new Promise((resolve, reject) => {
const actions = []
if (this.hasToUpdate(version, [2, 2, 9])) {
// remove the hamburger menu icon
const menuButton = doc.querySelector('.menu-button')
if (menuButton) {
menuButton.parentElement.removeChild(menuButton)
actions.push(
'I removed the mobile menu as there is now a component for that. Read more about the Hamburger Menu component here.',
)
}
}
resolve(actions)
})
}
to2_2_10(version: number[], doc: HTMLDocument): Promise {
return new Promise((resolve, reject) => {
const actions = []
if (this.hasToUpdate(version, [2, 2, 10])) {
// the body is a drop zone, not selectable, not draggable, resizeable
doc.body.classList.add(
Constants.PREVENT_DRAGGABLE_CLASS_NAME,
Constants.PREVENT_RESIZABLE_CLASS_NAME,
Constants.PREVENT_SELECTABLE_CLASS_NAME)
// each section background and foreground is a drop zone, not selectable, not draggable, resizeable
const changedSections = Array.from(doc.querySelectorAll(`.${ElementType.SECTION}`)) as HTMLElement[]
changedSections.forEach((el: HTMLElement) => el.classList.add(
Constants.PREVENT_DRAGGABLE_CLASS_NAME,
Constants.PREVENT_RESIZABLE_CLASS_NAME,
))
// we add classes to the elements so that we can tell the stage component if an element is draggable, resizeable, selectable...
const changedSectionsContent = Array.from(doc.querySelectorAll(`.${ElementType.SECTION}, .${ElementType.SECTION} .${SECTION_CONTAINER}`))
changedSectionsContent.forEach((el: HTMLElement) => el.classList.add(
Constants.PREVENT_DRAGGABLE_CLASS_NAME,
// Constants.PREVENT_RESIZABLE_LEFT_CLASS_NAME,
// Constants.PREVENT_RESIZABLE_RIGHT_CLASS_NAME
))
actions.push(`Changed the body and ${changedSections.length} sections with new CSS classes to the new stage component.`)
// types are now with a "-element" suffix
const changedElements = Array.from(doc.querySelectorAll(`[${Constants.TYPE_ATTR}]`))
changedElements.forEach((el: HTMLElement) => el.setAttribute(Constants.TYPE_ATTR, el.getAttribute(Constants.TYPE_ATTR) + '-element'))
actions.push(`Updated ${ changedElements.length } elements, changed their types to match the new version of Silex.`)
}
resolve(actions)
})
}
to2_2_11(version: number[], doc: HTMLDocument): Promise {
return new Promise((resolve, reject) => {
const actions = []
if (this.hasToUpdate(version, [2, 2, 11])) {
// the body is supposed to be an element too
doc.body.classList.add(Constants.EDITABLE_CLASS_NAME)
actions.push('I made the body editable.')
const oldBodyId = doc.body.getAttribute('data-silex-id')
if (oldBodyId !== 'body-initial') {
actions.push('I udpated the body class name from "' + oldBodyId + '" to "body-initial".')
doc.body.setAttribute('data-silex-id', 'body-initial')
doc.body.classList.remove(oldBodyId)
doc.body.classList.add('body-initial')
}
// prepare the dom
cleanupBefore(doc)
// import elements
const elements = getElementsFromDomBC(doc)
writeStyles(doc, elements)
// site
const site = getSiteFromDom(doc)
writeSiteStyles(doc, site)
// pages
const pages = getPagesFromDom(doc)
if (elements.length && pages.length && site) {
this.data = {
site,
pages,
elements,
}
this.removeIfExist(doc, 'meta[name="website-width"]')
this.removeIfExist(doc, 'meta[name="hostingProvider"]')
this.removeIfExist(doc, 'meta[name="publicationPath"]')
// remove juery-ui at publication
Array.from(doc.querySelectorAll('script[src$="pageable.js"], script[src$="jquery-ui.js"]'))
.forEach((tag) => tag.setAttribute(Constants.ATTR_REMOVE_PUBLISH, ''))
;['prevent-draggable', SECTION_CONTAINER].forEach((className) => this.removeUselessCSSClass(doc, className))
actions.push('I updated the model to the latest version of Silex.')
// pages
this.removeIfExist(doc, `.${Constants.PAGES_CONTAINER_CLASS_NAME}`)
actions.push('I removed the old pages system.')
} else {
console.error('Could not import site from v2.2.11', {elements, pages, site})
}
}
resolve(actions)
})
}
to2_2_12(version: number[], doc: HTMLDocument): Promise {
return new Promise((resolve, reject) => {
const actions = []
if (this.hasToUpdate(version, [2, 2, 12])) {
// add empty metadata to the website object
this.data = {
...this.data,
site: {
...this.data.site,
data: {},
}
}
actions.push('add empty metadata to the website object')
// sections are now section tag
const changedSections = Array.from(doc.querySelectorAll(`.${ElementType.SECTION}`)) as HTMLElement[]
changedSections.forEach((el: HTMLElement) => setTagName(el, 'section'))
this.data = {
...this.data,
elements: this.data.elements
.map((el) => ({
...el,
tagName: getDomElement(doc, el as ElementState) ? getDomElement(doc, el as ElementState).tagName : 'DIV',
}))
}
actions.push('All sections have a <SECTION> tag name')
}
resolve(actions)
})
}
to2_2_13(version: number[], doc: HTMLDocument): Promise {
return new Promise((resolve, reject) => {
const actions = []
if (this.hasToUpdate(version, [2, 2, 13])) {
Array.from(doc.querySelectorAll('[data-silex-href]'))
.forEach((tag: HTMLElement) => {
const newEl = setTagName(tag, 'A') as HTMLLinkElement
newEl.href = tag.getAttribute('data-silex-href')
newEl.removeAttribute('data-silex-href')
})
actions.push('Replaced old Silex links with standard HTML links.')
}
resolve(actions)
})
}
to2_2_14(version: number[], doc: HTMLDocument): Promise {
return new Promise((resolve, reject) => {
const actions = []
if (this.hasToUpdate(version, [2, 2, 14])) {
// do not fix this: remove w3c warning "The type attribute is unnecessary for JavaScript resources"
// because silex uses script type attr in WebsiteRouter
// instead we remove the type at publication time in DomPublisher
// remove w3c warning "The type attribute for the style element is not needed and should be omitted."
const styles = Array.from(doc.querySelectorAll('style[type="text/css"]'))
if(styles.length) {
styles.forEach(el => el.removeAttribute('type'))
actions.push(`Fixed ${styles.length} w3c warning 'The type attribute for the style element is not needed and should be omitted.'`)
}
// This should not be useful (previous BC bug?)
// Remove empty attributes
// Sometimes we have style="", title=""...
let numEmpty = 0
const attrs = [
'href',
'style',
'rel',
'target',
'type',
'title',
]
attrs.forEach((attr: string) => {
Array.from(doc.querySelectorAll(`[${attr}]`))
.forEach(el => {
if(el.hasAttribute(attr) && el.getAttribute(attr) === '') {
el.removeAttribute(attr)
numEmpty++
}
})
})
if(numEmpty) actions.push(`Removed ${numEmpty} empty attributes (${attrs.join(', ')})`)
// This should not be useful (previous BC bug?)
// Remove the href attribute from non tags
// Sometimes we have href="null" on non a tags
const tagsWithWrongHref = Array.from(doc.querySelectorAll('[href]:not(a, link, base)'))
tagsWithWrongHref.forEach(el => {
el.removeAttribute('href')
})
if(tagsWithWrongHref.length) actions.push(`Removed ${tagsWithWrongHref.length} href attributes on non links tags`)
}
resolve(actions)
})
}
}