Skip to content

Commit

Permalink
feat: adding support for zcli:theme preview
Browse files Browse the repository at this point in the history
  • Loading branch information
luis-almeida committed Apr 28, 2023
1 parent 7f1f4c5 commit 918bff3
Show file tree
Hide file tree
Showing 23 changed files with 1,153 additions and 3 deletions.
5 changes: 5 additions & 0 deletions packages/zcli-theme/bin/run
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env node

const oclif = require('@oclif/core')

oclif.run().catch(require('@oclif/core/handle'))
3 changes: 3 additions & 0 deletions packages/zcli-theme/bin/run.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@echo off

node "%~dp0\run" %*
60 changes: 60 additions & 0 deletions packages/zcli-theme/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"name": "@zendesk/zcli-theme",
"description": "zcli theme commands live here",
"version": "1.0.0-beta.32",
"author": "@zendesk/vikings",
"npmRegistry": "https://registry.npmjs.org",
"publishConfig": {
"access": "public"
},
"bin": {
"zcli-theme": "./bin/run"
},
"scripts": {
"build": "tsc",
"prepack": "tsc && ../../scripts/prepack.sh",
"postpack": "rm -f oclif.manifest.json npm-shrinkwrap.json && rm -rf ./dist && git checkout ./package.json",
"type:check": "tsc"
},
"dependencies": {
"axios": "^0.27.2",
"chalk": "^4.1.2",
"cors": "^2.8.5",
"express": "^4.17.1",
"glob": "^9.3.2",
"sass": "^1.60.0"
},
"devDependencies": {
"@oclif/test": "=2.1.0",
"@types/chai": "^4",
"@types/cors": "^2.8.6",
"@types/mocha": "^9.1.1",
"@types/sinon": "^10.0.13",
"chai": "^4",
"eslint": "^8.18.0",
"eslint-config-oclif": "^4.0.0",
"eslint-config-oclif-typescript": "^1.0.2",
"lerna": "^5.1.8",
"mocha": "^10.0.0",
"sinon": "^14.0.0"
},
"files": [
"/bin",
"/dist",
"/oclif.manifest.json",
"/npm-shrinkwrap.json"
],
"keywords": [
"zcli",
"zendesk",
"cli",
"command"
],
"license": "MIT",
"main": "src/index.js",
"oclif": {
"commands": "./src/commands",
"bin": "zcli-theme"
},
"types": "lib/index.d.ts"
}
102 changes: 102 additions & 0 deletions packages/zcli-theme/src/commands/theme/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Command, Flags } from '@oclif/core'
import * as path from 'path'
import * as fs from 'fs'
import * as express from 'express'
import * as morgan from 'morgan'
import * as chalk from 'chalk'
import * as cors from 'cors'
import getRuntimeContext from '../../lib/getRuntimeContext'
import preview from '../../lib/preview'
import getManifest from '../../lib/getManifest'
import getVariables from '../../lib/getVariables'
import getAssets from '../../lib/getAssets'
import zass from '../../lib/zass'

const logMiddleware = morgan((tokens, req, res) =>
`${chalk.green(tokens.method(req, res))} ${tokens.url(req, res)} ${chalk.bold(tokens.status(req, res))}`
)

export default class Server extends Command {
static description = 'preview a theme in development mode'

static flags = {
help: Flags.help({ char: 'h' }),
bind: Flags.string({ default: 'localhost', description: 'Bind theme server to a specific host' }),
port: Flags.integer({ default: 4567, description: 'Port for the http server to use' }),
logs: Flags.boolean({ default: false, description: 'Tail logs' }),
subdomain: Flags.string({ description: 'Account subdomain or full URL (including protocol)' }),
username: Flags.string({ description: 'Account username (email)' }),
password: Flags.string({ description: 'Account password' })
}

static args = [
{ name: 'themeDirectory', required: true, default: '.' }
]

static examples = [
'$ zcli theme:preview ./copenhagen_theme'
]

static strict = false

async run () {
const { flags } = await this.parse(Server)
const { argv: [themeDirectory] } = await this.parse(Server)
const themePath = path.resolve(themeDirectory)
const context = await getRuntimeContext(themePath, flags)
const { logs: tailLogs, port, host, origin } = context

await preview(themePath, context)

const app = express()
app.use(cors())
tailLogs && app.use(logMiddleware)

app.use('/guide/assets', express.static(`${themePath}/assets`))
app.use('/guide/settings', express.static(`${themePath}/settings`))

app.get('/guide/script.js', (req, res) => {
const script = path.resolve(`${themePath}/script.js`)
const source = fs.readFileSync(script, 'utf8')
res.header('Content-Type', 'text/javascript')
res.send(source)
})

app.get('/guide/style.css', (req, res) => {
const style = path.resolve(`${themePath}/style.css`)
const source = fs.readFileSync(style, 'utf8')
const manifest = getManifest(themePath)
const variables = getVariables(themePath, manifest.settings, context)
const assets = getAssets(themePath, context)
const compiled = zass(source, variables, assets)
res.header('Content-Type', 'text/css')
res.send(compiled)
})

const server = app.listen(port, host, () => {
console.log(chalk.bold.green('Ready', chalk.blueBright(`${origin}/hc/admin/local_preview/start`, '🚀')))
console.log(`You can exit preview mode in the UI or by visiting ${origin}/hc/admin/local_preview/stop`)
tailLogs && this.log(chalk.bold('Tailing logs'))
})

const monitoredPaths = [
`${themePath}/manifest.json`,
`${themePath}/templates`
]

// Keep references of watchers for unwatching later
const watchers = monitoredPaths.map(path =>
fs.watch(path, { recursive: true }, (eventType, filename) => {
console.log(chalk.bold.gray('Change'), filename)
preview(themePath, context)
}))

return {
close: () => {
// Stop watching file changes before terminating the server
watchers.forEach(watcher => watcher.close())
server.close()
}
}
}
}
1 change: 1 addition & 0 deletions packages/zcli-theme/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {}
50 changes: 50 additions & 0 deletions packages/zcli-theme/src/lib/getAssets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as sinon from 'sinon'
import * as fs from 'fs'
import { expect } from '@oclif/test'
import getAssets from './getAssets'

const context = {
bind: 'localhost',
port: 1000,
logs: true,
host: 'localhost',
subdomain: 'z3n',
username: '[email protected]',
password: '123456',
origin: 'https://z3n.zendesk.com'
}

describe('getAssets', () => {
beforeEach(() => {
sinon.restore()
})

it('returns an array of tupples containing the parsed path and url for each asset', () => {
const readdirSyncStub = sinon.stub(fs, 'readdirSync')

readdirSyncStub.returns(['.gitkeep', 'foo.png', 'bar.png'] as any)

const assets = getAssets('theme/path', context)

expect(assets).to.deep.equal([
[
{ base: 'foo.png', dir: '', ext: '.png', name: 'foo', root: '' },
'http://localhost:1000/guide/assets/foo.png'
],
[
{ base: 'bar.png', dir: '', ext: '.png', name: 'bar', root: '' },
'http://localhost:1000/guide/assets/bar.png'
]
])
})

it('throws and error when an asset has illegal characters in its name', () => {
const readdirSyncStub = sinon.stub(fs, 'readdirSync')

readdirSyncStub.returns(['unsuported file name.png'] as any)

expect(() => {
getAssets('theme/path', context)
}).to.throw('The asset "unsuported file name.png" has illegal characters in its name. Filenames should only have alpha-numerical characters, ., _, -, and +')
})
})
24 changes: 24 additions & 0 deletions packages/zcli-theme/src/lib/getAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { RuntimeContext } from '../types'
import { CLIError } from '@oclif/core/lib/errors'
import * as fs from 'fs'
import * as path from 'path'

export default function getAssets (themePath: string, context: RuntimeContext): [path.ParsedPath, string][] {
const filenames = fs.readdirSync(`${themePath}/assets`)
const assets: [path.ParsedPath, string][] = []

filenames.forEach(filename => {
const parsedPath = path.parse(filename)
const name = parsedPath.name.toLowerCase()
if (name.match(/[^a-z0-9-_+.]/)) {
throw new CLIError(
`The asset "${filename}" has illegal characters in its name. Filenames should only have alpha-numerical characters, ., _, -, and +`
)
}
if (!name.startsWith('.')) {
assets.push([parsedPath, `http://${context.host}:${context.port}/guide/assets/${filename}`])
}
})

return assets
}
62 changes: 62 additions & 0 deletions packages/zcli-theme/src/lib/getManifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as sinon from 'sinon'
import * as fs from 'fs'
import { expect } from '@oclif/test'
import getManifest from './getManifest'

describe('getManifest', () => {
beforeEach(() => {
sinon.restore()
})

it('returns the manifest.json file parsed as json', () => {
const existsSyncStub = sinon.stub(fs, 'existsSync')
const readFileSyncStub = sinon.stub(fs, 'readFileSync')

const manifest = {
name: 'Copenhagen theme',
author: 'Jane Doe',
version: '1.0.1',
api_version: 1,
settings: []
}

existsSyncStub
.withArgs('theme/path/manifest.json')
.returns(true)

readFileSyncStub
.withArgs('theme/path/manifest.json')
.returns(JSON.stringify(manifest))

expect(getManifest('theme/path')).to.deep.equal(manifest)
})

it('throws and error when it can\'t find a manifest.json file', () => {
const existsSyncStub = sinon.stub(fs, 'existsSync')

existsSyncStub
.withArgs('theme/path/manifest.json')
.returns(false)

expect(() => {
getManifest('theme/path')
}).to.throw('Couldn\'t find a manifest.json file at path: "theme/path/manifest.json"')
})

it('throws and error when the manifest.json file is malformed', () => {
const existsSyncStub = sinon.stub(fs, 'existsSync')
const readFileSyncStub = sinon.stub(fs, 'readFileSync')

existsSyncStub
.withArgs('theme/path/manifest.json')
.returns(true)

readFileSyncStub
.withArgs('theme/path/manifest.json')
.returns('{"name": "Copenhagen theme",,, }')

expect(() => {
getManifest('theme/path')
}).to.throw('manifest.json file was malformed at path: "theme/path/manifest.json"')
})
})
19 changes: 19 additions & 0 deletions packages/zcli-theme/src/lib/getManifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Manifest } from '../types'
import { CLIError } from '@oclif/core/lib/errors'
import * as fs from 'fs'
import * as chalk from 'chalk'

export default function getManifest (themePath: string): Manifest {
const manifestFilePath = `${themePath}/manifest.json`

if (fs.existsSync(manifestFilePath)) {
const manifestFile = fs.readFileSync(manifestFilePath, 'utf8')
try {
return JSON.parse(manifestFile)
} catch (error) {
throw new CLIError(chalk.red(`manifest.json file was malformed at path: "${manifestFilePath}"`))
}
} else {
throw new CLIError(chalk.red(`Couldn't find a manifest.json file at path: "${manifestFilePath}"`))
}
}
Loading

0 comments on commit 918bff3

Please sign in to comment.