diff --git a/src/asset-resolver.ts b/src/asset-resolver.ts new file mode 100644 index 0000000..610c13b --- /dev/null +++ b/src/asset-resolver.ts @@ -0,0 +1,29 @@ +import { URL } from 'url' +import path from 'path' + +const assetsEndpoint = '/assets' + +// https://tools.ietf.org/html/rfc3986#section-3.1 +const uriRegExp = /^\w[\w\d+-.]*:/ + +export class AssetResolver { + pathToUrl(assetPath: string, basePath: string): string { + // If it is full url, don't try to resolve it + if (uriRegExp.test(assetPath)) { + return assetPath + } + + const resolved = path.resolve(basePath, assetPath) + return assetsEndpoint + '?path=' + encodeURIComponent(resolved) + } + + urlToPath(assetUrl: string): string | null { + const url = new URL(assetUrl, 'file://') + if (url.pathname !== assetsEndpoint) { + return null + } + + const assetPath = url.searchParams.get('path') + return assetPath && decodeURIComponent(assetPath) + } +} diff --git a/src/message/bus.ts b/src/message/bus.ts index 5b8cd54..5ca89e6 100644 --- a/src/message/bus.ts +++ b/src/message/bus.ts @@ -4,23 +4,33 @@ import { VueFile, resolveImportPath, parseVueFile, - vueFileToPayload + vueFileToPayload as _vueFileToPayload } from '../parser/vue-file' import { getNode as getTemplateNode } from '../parser/template/manipulate' import { getNode as getStyleNode } from '../parser/style/manipulate' import { Modifiers, modify } from '../parser/modifier' import { insertComponentScript } from '../parser/script/modify' import { insertToTemplate } from '../parser/template/modify' -import { updateDeclaration, insertDeclaration, removeDeclaration } from '../parser/style/modify' +import { + updateDeclaration, + insertDeclaration, + removeDeclaration +} from '../parser/style/modify' +import { AssetResolver } from '../asset-resolver' import { mapValues } from '../utils' export function observeServerEvents( bus: MessageBus, + assetResolver: AssetResolver, vueFiles: Record, activeUri: string | undefined ): void { let lastActiveUri: string | undefined = activeUri + const vueFileToPayload = (vueFile: VueFile) => { + return _vueFileToPayload(vueFile, assetResolver) + } + bus.on('initClient', () => { bus.emit('initProject', mapValues(vueFiles, vueFileToPayload)) if (lastActiveUri) { @@ -137,9 +147,7 @@ export function observeServerEvents( bus.on('addDeclaration', ({ uri, path, declaration }) => { const { code, styles } = vueFiles[uri] - const added = modify(code, [ - insertDeclaration(styles, declaration, path) - ]) + const added = modify(code, [insertDeclaration(styles, declaration, path)]) bus.emit('updateEditor', { uri, @@ -155,9 +163,7 @@ export function observeServerEvents( bus.on('removeDeclaration', ({ uri, path }) => { const { code, styles } = vueFiles[uri] - const removed = modify(code, [ - removeDeclaration(styles, path) - ]) + const removed = modify(code, [removeDeclaration(styles, path)]) bus.emit('updateEditor', { uri, diff --git a/src/parser/style/manipulate.ts b/src/parser/style/manipulate.ts index fbb5f31..e00cc4f 100644 --- a/src/parser/style/manipulate.ts +++ b/src/parser/style/manipulate.ts @@ -1,35 +1,74 @@ import * as t from './types' -import { clone } from '../../utils' +import { clone, unquote } from '../../utils' +import { AssetResolver } from '../../asset-resolver' + +interface StyleVisitor { + atRule?(atRule: t.AtRule): t.AtRule | void + rule?(rule: t.Rule): t.Rule | void + declaration?(decl: t.Declaration): t.Declaration | void +} export const scopePrefix = 'data-scope-' -export function visitLastSelectors( - node: t.Style, - fn: (selector: t.Selector, rule: t.Rule) => t.Selector | void -): t.Style { - function loop(node: t.AtRule | t.Rule): t.AtRule | t.Rule - function loop( - node: t.AtRule | t.Rule | t.Declaration - ): t.AtRule | t.Rule | t.Declaration +function visitStyle(style: t.Style, visitor: StyleVisitor): t.Style { + function apply(node: T, visitor?: (node: T) => T | void): T { + return visitor ? visitor(node) || node : node + } + function loop( node: t.AtRule | t.Rule | t.Declaration ): t.AtRule | t.Rule | t.Declaration { switch (node.type) { case 'AtRule': - return clone(node, { + return clone(apply(node, visitor.atRule), { children: node.children.map(loop) }) case 'Rule': - return clone(node, { - selectors: node.selectors.map(s => fn(s, node) || s) + return clone(apply(node, visitor.rule), { + declarations: node.declarations.map(loop) }) - default: - // Do nothing - return node + case 'Declaration': + return apply(node, visitor.declaration) } } - return clone(node, { - body: node.body.map(b => loop(b)) + + return clone(style, { + body: style.body.map(loop) + }) +} + +export function visitLastSelectors( + node: t.Style, + fn: (selector: t.Selector, rule: t.Rule) => t.Selector | void +): t.Style { + return visitStyle(node, { + rule: rule => { + return clone(rule, { + selectors: rule.selectors.map(s => fn(s, rule) || s) + }) + } + }) +} + +export function resolveAsset( + style: t.Style, + basePath: string, + resolver: AssetResolver +): t.Style { + return visitStyle(style, { + declaration: decl => { + const value = decl.value + const replaced = value.replace(/url\(([^)]+)\)/g, (_, p) => { + const unquoted = unquote(p) + const resolved = resolver.pathToUrl(unquoted, basePath) + return 'url(' + (resolved ? JSON.stringify(resolved) : p) + ')' + }) + return replaced !== value + ? clone(decl, { + value: replaced + }) + : decl + } }) } diff --git a/src/parser/template/manipulate.ts b/src/parser/template/manipulate.ts index f4ace83..200e28e 100644 --- a/src/parser/template/manipulate.ts +++ b/src/parser/template/manipulate.ts @@ -1,6 +1,7 @@ import assert from 'assert' import * as t from './types' import { scopePrefix } from '../style/manipulate' +import { AssetResolver } from '../../asset-resolver' import { clone } from '../../utils' export function getNode( @@ -111,6 +112,29 @@ export function visitElements( }) } +export function resolveAsset( + template: t.Template, + baseUrl: string, + resolver: AssetResolver +): t.Template { + return visitElements(template, el => { + if (el.name === 'img') { + const resolvedAttrs = el.startTag.attributes.map(attr => { + if (attr.name !== 'src' || !attr.value) return attr + + return clone(attr, { + value: resolver.pathToUrl(attr.value, baseUrl) + }) + }) + return clone(el, { + startTag: clone(el.startTag, { + attributes: resolvedAttrs + }) + }) + } + }) +} + export function addScope(node: t.Template, scope: string): t.Template { return visitElements(node, el => { return clone(el, { diff --git a/src/parser/vue-file.ts b/src/parser/vue-file.ts index 5b69a45..97e2b7b 100644 --- a/src/parser/vue-file.ts +++ b/src/parser/vue-file.ts @@ -8,6 +8,7 @@ import postcssParse from 'postcss-safe-parser' import hashsum from 'hash-sum' import { Template } from './template/types' import { transformTemplate } from './template/transform' +import { resolveAsset as resolveTemplateAsset } from './template/manipulate' import { Prop, Data, ChildComponent } from './script/types' import { extractChildComponents, @@ -15,7 +16,9 @@ import { extractData } from './script/manipulate' import { Style } from './style/types' +import { resolveAsset as resolveStyleAsset } from './style/manipulate' import { transformStyle } from './style/transform' +import { AssetResolver } from '../asset-resolver' export interface VueFilePayload { uri: string @@ -77,17 +80,25 @@ export function parseVueFile(code: string, uri: string): VueFile { } } -export function vueFileToPayload(vueFile: VueFile): VueFilePayload { +export function vueFileToPayload( + vueFile: VueFile, + assetResolver: AssetResolver +): VueFilePayload { const scopeId = hashsum(vueFile.uri.toString()) + const basePath = path.dirname(vueFile.uri.pathname) return { uri: vueFile.uri.toString(), scopeId, - template: vueFile.template, + template: + vueFile.template && + resolveTemplateAsset(vueFile.template, basePath, assetResolver), props: vueFile.props, data: vueFile.data, childComponents: vueFile.childComponents, - styles: vueFile.styles + styles: vueFile.styles.map(s => + resolveStyleAsset(s, basePath, assetResolver) + ) } } diff --git a/src/server/main.ts b/src/server/main.ts index 8573801..cd792fc 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -6,6 +6,7 @@ import WebSocket from 'ws' import { EventObserver, CommandEmitter } from 'meck' import { ClientPayload, ServerPayload } from '../payload' import { Events, Commands } from '../message/types' +import { AssetResolver } from '../asset-resolver' function readContent( file: string, @@ -23,7 +24,7 @@ const html = ` const allowedUrls = ['/vue-designer-view.js'] -export function startStaticServer(): http.Server { +export function startStaticServer(assetResolver: AssetResolver): http.Server { const server = http.createServer((req, res) => { if (req.headers.host && !/^localhost(:\d+)?$/.test(req.headers.host)) { res.statusCode = 403 @@ -31,17 +32,32 @@ export function startStaticServer(): http.Server { return } - if (req.url === '/' || req.url === '/index.html') { - res.end(html) - return - } - - if (req.url && allowedUrls.indexOf(req.url) >= 0) { - readContent(req.url, (err, content) => { - assert(!err, 'Unexpectedly file not found') - res.end(content) - }) - return + if (req.url) { + if (req.url === '/' || req.url === '/index.html') { + res.end(html) + return + } + + const assetPath = assetResolver.urlToPath(req.url) + if (assetPath) { + fs.readFile(assetPath, (err, data) => { + if (err) { + res.statusCode = 500 + res.end(err.message) + return + } + res.end(data) + }) + return + } + + if (allowedUrls.indexOf(req.url) >= 0) { + readContent(req.url, (err, content) => { + assert(!err, 'Unexpectedly file not found') + res.end(content) + }) + return + } } res.statusCode = 404 diff --git a/src/utils.ts b/src/utils.ts index a39269c..c51e563 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -37,7 +37,7 @@ export function flatten(list: (T | T[])[]): T[] { }, []) } -export function clone(value: T, changes: any): T { +export function clone(value: T, changes: any = {}): T { return { ...(value as any), ...changes @@ -57,3 +57,12 @@ export function minmax(min: number, n: number, max: number): number { export function isObject(value: any): boolean { return value !== null && typeof value === 'object' } + +export function unquote(str: string): string { + const quotes = str[0] + str[str.length - 1] + if (quotes === "''" || quotes === '""') { + return str.slice(1, -1) + } else { + return str + } +} diff --git a/src/vue-designer.ts b/src/vue-designer.ts index 2f42c26..56b7a84 100644 --- a/src/vue-designer.ts +++ b/src/vue-designer.ts @@ -9,6 +9,7 @@ import { import { VueFile } from './parser/vue-file' import { Commands, Events } from './message/types' import { observeServerEvents } from './message/bus' +import { AssetResolver } from './asset-resolver' function createHighlight(): vscode.TextEditorDecorationType { return vscode.window.createTextEditorDecorationType({ @@ -122,9 +123,10 @@ function createVSCodeCommandEmitter(): CommandEmitter { export function activate(context: vscode.ExtensionContext) { const vueFiles: Record = {} + const assetResolver = new AssetResolver() const previewUri = vscode.Uri.parse('vue-designer://authority/vue-designer') - const server = startStaticServer() + const server = startStaticServer(assetResolver) const wsServer = startWebSocketServer(server) const editor = vscode.window.activeTextEditor @@ -134,7 +136,7 @@ export function activate(context: vscode.ExtensionContext) { [wsEventObserver(wsServer), createVSCodeEventObserver()], [wsCommandEmiter(wsServer), createVSCodeCommandEmitter()] ) - observeServerEvents(bus, vueFiles, activeUri) + observeServerEvents(bus, assetResolver, vueFiles, activeUri) const serverPort = process.env.DEV ? 50000 : server.address().port console.log(`Vue Designer server listening at http://localhost:${serverPort}`) diff --git a/test/asset-resolver.spec.ts b/test/asset-resolver.spec.ts new file mode 100644 index 0000000..5f64c10 --- /dev/null +++ b/test/asset-resolver.spec.ts @@ -0,0 +1,36 @@ +import { AssetResolver } from '@/asset-resolver' + +describe('AssetResolver', () => { + const asset = new AssetResolver() + + it('converts path to url', () => { + const url = asset.pathToUrl('../assets/logo.png', '/path/to/components') + expect(url).toBe( + '/assets?path=' + encodeURIComponent('/path/to/assets/logo.png') + ) + }) + + it('should not convert url', () => { + const value = 'https://example.com/logo.png' + const url = asset.pathToUrl(value, '/path/to/components') + expect(url).toBe(value) + }) + + it('convers url to path', () => { + const expected = '/path/to/assets/logo.png' + const path = asset.urlToPath('/assets?path=' + encodeURIComponent(expected)) + expect(path).toBe(expected) + }) + + it('returns null if invalid format', () => { + const invalidEndpoint = asset.urlToPath( + '/assets/foo?path=' + encodeURIComponent('/logo.png') + ) + expect(invalidEndpoint).toBe(null) + + const noPath = asset.urlToPath( + '/assets?test=' + encodeURIComponent('/logo.png') + ) + expect(noPath).toBe(null) + }) +}) diff --git a/test/parser/style/asset.spec.ts b/test/parser/style/asset.spec.ts new file mode 100644 index 0000000..230f927 --- /dev/null +++ b/test/parser/style/asset.spec.ts @@ -0,0 +1,55 @@ +import { AssetResolver } from '@/asset-resolver' +import { createStyle, rule, selector, declaration } from '../../helpers/style' +import { resolveAsset } from '@/parser/style/manipulate' +import { Rule } from '@/parser/style/types' + +describe('Style asset resolution', () => { + const basePath = '/path/to/components' + const asset = new AssetResolver() + + it('resolves assets in url function', () => { + const style = createStyle([ + rule( + [selector({ tag: 'p' })], + [declaration('background', 'url(../assets/bg.png)')] + ) + ]) + + const resolved = resolveAsset(style, basePath, asset) + const decl = (resolved.body[0] as Rule).declarations[0] + expect(decl.prop).toBe('background') + expect(decl.value).toBe( + 'url("/assets?path=' + encodeURIComponent('/path/to/assets/bg.png') + '")' + ) + }) + + it('resolves url function with other kind of values', () => { + const style = createStyle([ + rule( + [selector({ tag: 'p' })], + [ + declaration('font-size', '18px'), + declaration( + 'background', + 'cyan url("../assets/bg.png") repeat, url("test/icon.gif") no-repeat' + ) + ] + ) + ]) + + const resolved = resolveAsset(style, basePath, asset) + const decls = (resolved.body[0] as Rule).declarations + expect(decls.length).toBe(2) + expect(decls[0].prop).toBe('font-size') + expect(decls[0].value).toBe('18px') + expect(decls[1].prop).toBe('background') + expect(decls[1].value).toBe( + 'cyan url("/assets?path=' + + encodeURIComponent('/path/to/assets/bg.png') + + '") repeat, ' + + 'url("/assets?path=' + + encodeURIComponent('/path/to/components/test/icon.gif') + + '") no-repeat' + ) + }) +}) diff --git a/test/parser/template/asset.spec.ts b/test/parser/template/asset.spec.ts new file mode 100644 index 0000000..8f8d8e4 --- /dev/null +++ b/test/parser/template/asset.spec.ts @@ -0,0 +1,32 @@ +import { AssetResolver } from '@/asset-resolver' +import { createTemplate, h, a } from '../../helpers/template' +import { resolveAsset } from '@/parser/template/manipulate' +import { Element } from '@/parser/template/types' + +describe('Template asset resolution', () => { + const basePath = '/path/to/component' + const asset = new AssetResolver() + + it('resolves all src paths on img elements', () => { + const template = createTemplate([ + h('img', [a('src', '../assets/logo.png'), a('alt', 'test')], []) + ]) + + const resolved = resolveAsset(template, basePath, asset) + const img = resolved.children[0] as Element + const attrs = img.startTag.attributes + + expect(img.name).toBe('img') + expect(attrs.length).toBe(2) + + // should convert src value + expect(attrs[0].name).toBe('src') + expect(attrs[0].value).toBe( + '/assets?path=' + encodeURIComponent('/path/to/assets/logo.png') + ) + + // should not touch other attribute + expect(attrs[1].name).toBe('alt') + expect(attrs[1].value).toBe('test') + }) +})