diff --git a/player/src/index.ts b/player/src/index.ts index f439112..06e1cef 100644 --- a/player/src/index.ts +++ b/player/src/index.ts @@ -2,11 +2,13 @@ /// import * as querystring from "querystring"; -let qs: { project: string; build: string; } = querystring.parse(window.location.search.slice(1)); +const qs: { project: string; build: string; } = querystring.parse(window.location.search.slice(1)); + +const indexPath = `/builds/${qs.project}/${qs.build}/files/index.html`; if ((window as any).SupApp != null) { - SupApp.openLink(`${window.location.origin}/builds/${qs.project}/${qs.build}/index.html`); + SupApp.openLink(`${window.location.origin}${indexPath}`); window.close(); } else { - window.location.href = `/builds/${qs.project}/${qs.build}/index.html`; + window.location.href = indexPath; } diff --git a/plugins/default/blob/data/BlobAsset.ts b/plugins/default/blob/data/BlobAsset.ts index e5e535f..2a1f817 100644 --- a/plugins/default/blob/data/BlobAsset.ts +++ b/plugins/default/blob/data/BlobAsset.ts @@ -79,8 +79,8 @@ export default class BlobAsset extends SupCore.Data.Base.Asset { }); } - serverExport(buildPath: string, assetsById: { [id: string]: BlobAsset }, callback: (err: Error) => void) { - if (this.pub.buffer == null) { callback (null); return; } + serverExport(buildPath: string, assetsById: { [id: string]: BlobAsset }, callback: (err: Error, writtenFiles: string[]) => void) { + if (this.pub.buffer == null) { callback (null, []); return; } let pathFromId = this.server.data.entries.getPathFromId(this.id); if (pathFromId.lastIndexOf(".") <= pathFromId.lastIndexOf("/")) { @@ -90,7 +90,11 @@ export default class BlobAsset extends SupCore.Data.Base.Asset { let outputPath = `${buildPath}/${pathFromId}`; let parentPath = outputPath.slice(0, outputPath.lastIndexOf("/")); - mkdirp(parentPath, () => { fs.writeFile(outputPath, this.pub.buffer, callback); }); + mkdirp(parentPath, () => { + fs.writeFile(outputPath, this.pub.buffer, (err) => { + callback(err, [ pathFromId ]); + }); + }); } server_upload(client: any, mediaType: string, buffer: Buffer, callback: UploadCallback) { diff --git a/plugins/default/export/build/WebBuildSettings.d.ts b/plugins/default/export/build/WebBuildSettings.d.ts new file mode 100644 index 0000000..11161cf --- /dev/null +++ b/plugins/default/export/build/WebBuildSettings.d.ts @@ -0,0 +1,3 @@ +interface WebBuildSettings { + outputFolder: string; +} diff --git a/plugins/default/export/build/WebBuildSettingsEditor.ts b/plugins/default/export/build/WebBuildSettingsEditor.ts new file mode 100644 index 0000000..b904140 --- /dev/null +++ b/plugins/default/export/build/WebBuildSettingsEditor.ts @@ -0,0 +1,96 @@ +import * as TreeView from "dnd-tree-view"; + +let outputFolder: string; + +export default class WebBuildSettingsEditor implements SupClient.BuildSettingsEditor { + private outputFolderTextfield: HTMLInputElement; + private outputFolderButton: HTMLButtonElement; + private errorRowElt: HTMLTableRowElement; + private errorInput: HTMLInputElement; + + private table: HTMLTableElement; + + constructor(container: HTMLDivElement, private entries: SupCore.Data.Entries, private entriesTreeView: TreeView) { + const { table, tbody } = SupClient.table.createTable(container); + this.table = table; + table.classList.add("properties"); + table.hidden = true; + + const outputFolderRow = SupClient.table.appendRow(tbody, SupClient.i18n.t("buildSettingsEditors:web.outputFolder")); + const inputs = SupClient.html("div", "inputs", { parent: outputFolderRow.valueCell }); + + const value = outputFolder != null ? outputFolder : ""; + this.outputFolderTextfield = SupClient.html("input", { parent: inputs, type: "text", value, readOnly: true, style: { cursor: "pointer" } }) as HTMLInputElement; + this.outputFolderButton = SupClient.html("button", { parent: inputs, textContent: SupClient.i18n.t("common:actions.select") }) as HTMLButtonElement; + + this.outputFolderTextfield.addEventListener("click", (event) => { event.preventDefault(); this.selectOutputfolder(); }); + this.outputFolderButton.addEventListener("click", (event) => { event.preventDefault(); this.selectOutputfolder(); }); + + const errorRow = SupClient.table.appendRow(tbody, "Error"); + this.errorRowElt = errorRow.row; + this.errorRowElt.hidden = true; + this.errorInput = SupClient.html("input", { parent: errorRow.valueCell, type: "text", readOnly: true }) as HTMLInputElement; + } + + setVisible(visible: boolean) { + this.table.hidden = !visible; + } + + getSettings(callback: (settings: WebBuildSettings) => void) { + this.ensureOutputFolderValid((outputFolderValid) => { + callback(outputFolderValid ? { outputFolder } : null); + }); + } + + private selectOutputfolder() { + SupApp.chooseFolder((folderPath) => { + if (folderPath == null) return; + + outputFolder = this.outputFolderTextfield.value = folderPath; + this.ensureOutputFolderValid(); + }); + } + + private ensureOutputFolderValid(callback?: (outputFolderValid: boolean) => void) { + if (outputFolder == null) { + this.displayError(SupClient.i18n.t("buildSettingsEditors:web.errors.selectDestionationFolder")); + if (callback != null) callback(false); + return; + } + + SupApp.readDir(outputFolder, (err, files) => { + if (err != null) { + this.displayError(SupClient.i18n.t("buildSettingsEditors:web.errors.emptyDirectoryCheckFail")); + console.log(err); + if (callback != null) callback(false); + return; + } + + let index = 0; + while (index < files.length) { + const item = files[index]; + if (item[0] === "." || item === "Thumbs.db") files.splice(index, 1); + else index++; + } + + if (files.length > 0) { + this.displayError(SupClient.i18n.t("buildSettingsEditors:web.errors.destinationFolderEmpty")); + if (callback != null) callback(false); + } else { + this.errorRowElt.hidden = true; + if (callback != null) callback(true); + } + }); + } + + private displayError(err: string) { + this.errorRowElt.hidden = false; + this.errorInput.value = err; + + (this.errorRowElt as any).animate([ + { transform: "translateX(0)" }, + { transform: "translateX(1.5vh)" }, + { transform: "translateX(0)" } + ], { duration: 100 }); + } +} diff --git a/plugins/default/export/build/buildWeb.ts b/plugins/default/export/build/buildWeb.ts new file mode 100644 index 0000000..d36096c --- /dev/null +++ b/plugins/default/export/build/buildWeb.ts @@ -0,0 +1,72 @@ +import * as async from "async"; +import * as querystring from "querystring"; +import supFetch from "../../../../../../SupClient/src/fetch"; +import * as path from "path"; + +const qs = querystring.parse(window.location.search.slice(1)); + +let settings: WebBuildSettings; + +const progress = { index: 0, total: 0, errors: 0 }; +const statusElt = document.querySelector(".status"); +const progressElt = document.querySelector("progress") as HTMLProgressElement; +const detailsListElt = document.querySelector(".details ol") as HTMLOListElement; + +export default function build(socket: SocketIOClient.Socket, theSettings: WebBuildSettings, buildPort: number) { + settings = theSettings; + + socket.emit("build:project", (err: string, buildId: string) => { + const buildPath = `${window.location.protocol}//${window.location.hostname}:${buildPort}/builds/${qs.project}/${buildId}/`; + + supFetch(`${buildPath}files.json`, "json", (err, filesToDownload) => { + if (err != null) { + progress.errors++; + SupClient.html("li", { parent: detailsListElt, textContent: SupClient.i18n.t("builds:web.errors.exportFailed", { path: settings.outputFolder }) }); + return; + } + + progress.total = filesToDownload.length; + updateProgress(); + + async.each(filesToDownload as string[], (filePath, cb) => { + downloadFile(buildPath, filePath, (err) => { + if (err != null) { + progress.errors++; + SupClient.html("li", { parent: detailsListElt, textContent: SupClient.i18n.t("builds:web.errors.exportFailed", { path: filePath }) }); + } else { + progress.index++; + updateProgress(); + } + + cb(err); + }); + }); + }); + }); +} + +function updateProgress() { + progressElt.max = progress.total; + progressElt.value = progress.index; + + if (progress.index < progress.total) { + statusElt.textContent = SupClient.i18n.t("builds:web.progress", { path: settings.outputFolder, index: progress.index, total: progress.total }); + } else if (progress.errors > 0) { + statusElt.textContent = SupClient.i18n.t("builds:web.doneWithErrors", { path: settings.outputFolder, total: progress.total, errors: progress.errors }); + } else { + statusElt.textContent = SupClient.i18n.t("builds:web.done", { path: settings.outputFolder, total: progress.total }); + } +} + +function downloadFile(buildPath: string, filePath: string, callback: ErrorCallback) { + const inputPath = `${buildPath}files/${filePath}`; + const outputPath = path.join(settings.outputFolder, filePath); + + SupApp.mkdirp(path.dirname(outputPath), (err) => { + supFetch(inputPath, "arraybuffer", (err, data) => { + if (err != null) { callback(err); return; } + + SupApp.writeFile(outputPath, new Buffer(data), callback); + }); + }); +} diff --git a/plugins/default/export/build/index.ts b/plugins/default/export/build/index.ts new file mode 100644 index 0000000..29fa66b --- /dev/null +++ b/plugins/default/export/build/index.ts @@ -0,0 +1,9 @@ +/// + +import WebBuildSettingsEditor from "./WebBuildSettingsEditor"; +import buildWeb from "./buildWeb"; + +SupClient.registerPlugin("build", "web", { + settingsEditor: WebBuildSettingsEditor, + build: buildWeb +}); diff --git a/plugins/default/export/index.d.ts b/plugins/default/export/index.d.ts new file mode 100644 index 0000000..d215d34 --- /dev/null +++ b/plugins/default/export/index.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/plugins/default/export/package.json b/plugins/default/export/package.json new file mode 100644 index 0000000..d224f11 --- /dev/null +++ b/plugins/default/export/package.json @@ -0,0 +1,13 @@ +{ + "name": "superpowers-web-default-export-plugin", + "description": "Export plugin for Superpowers Web, the collaborative static site editor", + "version": "1.0.0", + "license": "ISC", + "repository": { + "type": "git", + "url": "https://github.com/superpowers/superpowers-web.git" + }, + "scripts": { + "build": "gulp --gulpfile=../../../../../scripts/pluginGulpfile.js --cwd=." + } +} diff --git a/plugins/default/export/public/locales/en/buildSettingsEditors.json b/plugins/default/export/public/locales/en/buildSettingsEditors.json new file mode 100644 index 0000000..b72924f --- /dev/null +++ b/plugins/default/export/public/locales/en/buildSettingsEditors.json @@ -0,0 +1,13 @@ +{ + "web": { + "label": "Default", + "description": "Export as HTML5", + + "outputFolder": "Output folder", + "errors": { + "selectDestionationFolder": "Select a destination folder.", + "emptyDirectoryCheckFail": "Failed to check if the selected folder is empty.", + "destinationFolderEmpty": "The destination folder must be empty." + } + } +} diff --git a/plugins/default/export/public/locales/en/builds.json b/plugins/default/export/public/locales/en/builds.json new file mode 100644 index 0000000..45b7302 --- /dev/null +++ b/plugins/default/export/public/locales/en/builds.json @@ -0,0 +1,12 @@ +{ + "web": { + "title": "HTML5 export", + "progress": "Exporting to ${path}... (${index}/${total})", + "doneWithErrors": "Encountered ${errors} errors while exporting ${total} elements to ${path}.", + "done": "Exported ${total} elements to ${path}.", + + "errors": { + "exportFailed": "Failed to export ${path}." + } + } +} diff --git a/plugins/default/export/tsconfig.json b/plugins/default/export/tsconfig.json new file mode 100644 index 0000000..d322f2a --- /dev/null +++ b/plugins/default/export/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "noImplicitAny": true + } +} diff --git a/plugins/default/jade/data/JadeAsset.ts b/plugins/default/jade/data/JadeAsset.ts index 05f83d9..9317df6 100644 --- a/plugins/default/jade/data/JadeAsset.ts +++ b/plugins/default/jade/data/JadeAsset.ts @@ -96,7 +96,7 @@ export default class JadeAsset extends SupCore.Data.Base.Asset { }); } - serverExport(buildPath: string, assetsById: { [id: string]: JadeAsset }, callback: (err: Error) => void) { + serverExport(buildPath: string, assetsById: { [id: string]: JadeAsset }, callback: (err: Error, writtenFiles: string[]) => void) { let pathFromId = this.server.data.entries.getPathFromId(this.id); if (pathFromId.lastIndexOf(".jade") === pathFromId.length - 5) pathFromId = pathFromId.slice(0, -5); let outputPath = `${buildPath}/${pathFromId}.html`; @@ -139,7 +139,11 @@ export default class JadeAsset extends SupCore.Data.Base.Asset { } fs.readFileSync = oldReadFileSync; - mkdirp(parentPath, () => { fs.writeFile(outputPath, html, callback); }); + mkdirp(parentPath, () => { + fs.writeFile(outputPath, html, (err) => { + callback(err, [ `${pathFromId}.html` ]); + }); + }); } server_editText(client: any, operationData: OperationData, revisionIndex: number, callback: EditTextCallback) { diff --git a/plugins/default/json/data/JSONAsset.ts b/plugins/default/json/data/JSONAsset.ts index a493247..ae53318 100644 --- a/plugins/default/json/data/JSONAsset.ts +++ b/plugins/default/json/data/JSONAsset.ts @@ -89,14 +89,18 @@ export default class JSONAsset extends SupCore.Data.Base.Asset { }); } - serverExport(buildPath: string, assetsById: { [id: string]: JSONAsset }, callback: (err: Error) => void) { + serverExport(buildPath: string, assetsById: { [id: string]: JSONAsset }, callback: (err: Error, writtenFiles: string[]) => void) { let pathFromId = this.server.data.entries.getPathFromId(this.id); if (pathFromId.lastIndexOf(".json") === pathFromId.length - 5) pathFromId = pathFromId.slice(0, -5); let outputPath = `${buildPath}/${pathFromId}.json`; let parentPath = outputPath.slice(0, outputPath.lastIndexOf("/")); let text = this.pub.text; - mkdirp(parentPath, () => { fs.writeFile(outputPath, text, callback); }); + mkdirp(parentPath, () => { + fs.writeFile(outputPath, text, (err) => { + callback(err, [ `${pathFromId}.json` ]); + }); + }); } server_editText(client: any, operationData: OperationData, revisionIndex: number, callback: EditTextCallback) { diff --git a/plugins/default/stylus/data/StylusAsset.ts b/plugins/default/stylus/data/StylusAsset.ts index d1c1930..96344b3 100644 --- a/plugins/default/stylus/data/StylusAsset.ts +++ b/plugins/default/stylus/data/StylusAsset.ts @@ -94,7 +94,7 @@ export default class StylusAsset extends SupCore.Data.Base.Asset { }); } - serverExport(buildPath: string, assetsById: { [id: string]: StylusAsset }, callback: (err: Error) => void) { + serverExport(buildPath: string, assetsById: { [id: string]: StylusAsset }, callback: (err: Error, writtenFiles: string[]) => void) { let pathFromId = this.server.data.entries.getPathFromId(this.id); if (pathFromId.lastIndexOf(".styl") === pathFromId.length - 5) pathFromId = pathFromId.slice(0, -5); let outputPath = `${buildPath}/${pathFromId}.css`; @@ -116,7 +116,11 @@ export default class StylusAsset extends SupCore.Data.Base.Asset { }; let css = stylus(this.pub.text).set("filename", `${pathFromId}.styl`).set("cache", false).render(); fs.readFileSync = oldReadFileSync; - mkdirp(parentPath, () => { fs.writeFile(outputPath, css, callback); }); + mkdirp(parentPath, () => { + fs.writeFile(outputPath, css, (err) => { + callback(err, [ `${pathFromId}.css` ]); + }); + }); } server_editText(client: any, operationData: OperationData, revisionIndex: number, callback: EditTextCallback) { diff --git a/server/index.ts b/server/index.ts index 2793ca8..ea270b4 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,25 +1,29 @@ /// import * as async from "async"; +import * as fs from "fs"; -interface ExportableAsset extends SupCore.Data.Base.Asset { - serverExport: (outputPath: string, assetsById: { [id: string]: ExportableAsset }, callback: (err: Error) => void) => void; +interface ServerExportableAsset extends SupCore.Data.Base.Asset { + serverExport: (outputPath: string, assetsById: { [id: string]: ServerExportableAsset }, callback: (err: Error, writtenFiles: string[]) => void) => void; } SupCore.system.serverBuild = (server: ProjectServer, buildPath: string, callback: (err: string) => void) => { const assetIdsToExport: string[] = []; + let files: string[] = []; server.data.entries.walk((entry: SupCore.Data.EntryNode, parent: SupCore.Data.EntryNode) => { - if (entry.type != null) assetIdsToExport.push(entry.id); + if (entry.type == null) return; + + assetIdsToExport.push(entry.id); }); - const assetsById: { [id: string]: ExportableAsset } = {}; + const assetsById: { [id: string]: ServerExportableAsset } = {}; async.series([ // Acquire all assets (cb) => { async.each(assetIdsToExport, (assetId, cb) => { - server.data.assets.acquire(assetId, null, (err: Error, asset: ExportableAsset) => { + server.data.assets.acquire(assetId, null, (err: Error, asset: ServerExportableAsset) => { assetsById[assetId] = asset; cb(); }); @@ -29,7 +33,8 @@ SupCore.system.serverBuild = (server: ProjectServer, buildPath: string, callback // Export all assets (cb) => { async.each(assetIdsToExport, (assetId, cb) => { - assetsById[assetId].serverExport(buildPath, assetsById, () => { + assetsById[assetId].serverExport(`${buildPath}/files`, assetsById, (err, writtenFiles) => { + files = files.concat(writtenFiles); cb(); }); }, cb); @@ -39,6 +44,7 @@ SupCore.system.serverBuild = (server: ProjectServer, buildPath: string, callback // Release all assets for (const assetId of assetIdsToExport) server.data.assets.release(assetId, null); - callback(null); + // Write files.json + fs.writeFile(`${buildPath}/files.json`, JSON.stringify(files), callback); }); };