Skip to content

Commit

Permalink
feat: resolve assets in preview (#31)
Browse files Browse the repository at this point in the history
* chore: add asset resolver

* chore: transform src attribute on img tag

* feat: show image via img element in preview

* chore: resolve assets specified in url function on styles

* feat: preview style assets

* fix: should not resolve full url of assets
  • Loading branch information
ktsn committed May 5, 2018
1 parent ff8611d commit 668450c
Show file tree
Hide file tree
Showing 11 changed files with 302 additions and 43 deletions.
29 changes: 29 additions & 0 deletions src/asset-resolver.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 14 additions & 8 deletions src/message/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Events, Commands>,
assetResolver: AssetResolver,
vueFiles: Record<string, VueFile>,
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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
73 changes: 56 additions & 17 deletions src/parser/style/manipulate.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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
}
})
}

Expand Down
24 changes: 24 additions & 0 deletions src/parser/template/manipulate.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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, {
Expand Down
17 changes: 14 additions & 3 deletions src/parser/vue-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ 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,
extractProps,
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
Expand Down Expand Up @@ -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)
)
}
}

Expand Down
40 changes: 28 additions & 12 deletions src/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,25 +24,40 @@ const html = `<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
res.end()
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
Expand Down
11 changes: 10 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function flatten<T>(list: (T | T[])[]): T[] {
}, [])
}

export function clone<T>(value: T, changes: any): T {
export function clone<T>(value: T, changes: any = {}): T {
return {
...(value as any),
...changes
Expand All @@ -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
}
}
6 changes: 4 additions & 2 deletions src/vue-designer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -122,9 +123,10 @@ function createVSCodeCommandEmitter(): CommandEmitter<Commands> {

export function activate(context: vscode.ExtensionContext) {
const vueFiles: Record<string, VueFile> = {}
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
Expand All @@ -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}`)
Expand Down

0 comments on commit 668450c

Please sign in to comment.