Skip to content

Commit

Permalink
feat: Change to directive based workflow
Browse files Browse the repository at this point in the history
BREAKING CHANGE: This completely reworks the plugin interface.
Documentation is coming soon
  • Loading branch information
JonasKruckenberg committed Jan 30, 2021
1 parent 9764a33 commit 0f1b5b1
Show file tree
Hide file tree
Showing 13 changed files with 237 additions and 218 deletions.
14 changes: 14 additions & 0 deletions src/directives/fit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Directive } from "./index"
import pm from 'picomatch'

export const fitDirective: Directive = {
name: 'fit',
test(key: string, value: string) {
if (key === 'fit') return true

return pm.isMatch(key, ['cover', 'contain', 'fill', 'inside', 'outside']) && value === ''
},
transform(key: string, value: string) {
return { fit: !!value ? value : key }
}
}
15 changes: 15 additions & 0 deletions src/directives/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Directive } from "./index"
import pm from 'picomatch'


export const formatDirective: Directive = {
name: 'format',
test(key: string, value: string) {
if (key === 'format') return true

return pm.isMatch(key, ['jpeg', 'webp', 'avif', 'png', 'gif', 'tiff', 'heif']) && value === ''
},
transform(key: string, value: string) {
return { format: !!value ? value : key }
}
}
11 changes: 11 additions & 0 deletions src/directives/height.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Directive } from "./index"

export const heightDirective: Directive = {
name: 'height',
test(key: string, value: string) {
return key == 'height' && !isNaN(parseInt(value))
},
transform(key: string, value: string) {
return { height: parseInt(value) }
}
}
27 changes: 27 additions & 0 deletions src/directives/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { sizeDirective } from './size'
import { fitDirective } from './fit'
import { widthDirective } from './width'
import { heightDirective } from './height'
import { formatDirective } from './format'
import { positionDirective } from './position'
import { kernelDirective } from './kernel'
import { srcsetDirective } from './srcset'

export interface Directive {
name: string,
test: (key: string, value: string) => boolean
transform?: (key: string, value: string) => Record<string, any>
}

export function directives(): Directive[] {
return [
sizeDirective,
widthDirective,
heightDirective,
formatDirective,
positionDirective,
fitDirective,
kernelDirective,
srcsetDirective
]
}
9 changes: 9 additions & 0 deletions src/directives/kernel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Directive } from "./index"
import pm from 'picomatch'

export const kernelDirective: Directive = {
name: 'kernel',
test(key: string, value: string) {
return key === 'kernel' && pm.isMatch(value, ['nearest', 'cubic', 'mitchell', 'lanczos2', 'lanczos3'])
}
}
14 changes: 14 additions & 0 deletions src/directives/position.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Directive } from "./index"
import pm from 'picomatch'

export const positionDirective: Directive = {
name: 'position',
test(key: string, value: string) {
if (key === 'position') return true

return pm.isMatch(key, ['top', 'right top', 'right', 'right bottom', 'bottom', 'left bottom', 'left', 'left top', 'north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest', 'center', 'centre', 'entropy', 'attention']) && value === ''
},
transform(key: string, value: string) {
return { position: !!value ? value : key }
}
}
22 changes: 22 additions & 0 deletions src/directives/size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Directive } from "./index"
import { heightDirective } from "./height"
import { widthDirective } from "./width"
import pm from 'picomatch'

export const sizeDirective: Directive = {
name: 'size',
test(key: string, value: string) {
return key === 'size' && pm.isMatch(value, ['+([0-9])x+([0-9])'])
},
transform(_: string, value: string) {
if (~value.indexOf('x')) {
const [width, height] = value.split('x')
return {
...widthDirective.transform('width', width),
...heightDirective.transform('height', height)
}
} else {
return widthDirective.transform('width', value)
}
}
}
11 changes: 11 additions & 0 deletions src/directives/srcset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Directive } from "."

export const srcsetDirective: Directive = {
name: 'size',
test(key: string, value: string) {
return key === 'size' && value === ''
},
transform() {
return { srcset: true }
}
}
11 changes: 11 additions & 0 deletions src/directives/width.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Directive } from "./index"

export const widthDirective: Directive = {
name: 'width',
test(key: string, value: string) {
return key === 'width' && !isNaN(parseInt(value))
},
transform(_: string, value: string) {
return { width: parseInt(value) }
}
}
203 changes: 81 additions & 122 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,135 +1,94 @@
import { createFilter, dataToEsm } from 'rollup-pluginutils'
import MagicString from 'magic-string'
import pm from "picomatch"
import sharp from 'sharp'
import { Options, Target } from './types'
import { cleanUrl, assetUrlQuotedRE, transformId } from './util'
import cacache from 'cacache'
import { buildOptions, has, Options } from "./options"
import { directives } from './directives'
import { createHash } from 'crypto'
import path from 'path'
import { PluginContext } from 'rollup'

const defaultOptions: Options = {
include: ['**/*.png', '**/*.jpg', '**/*.jpeg', '**/*.gif', '**/*.webp'],
exclude: '',
targets: [{ format: 'jpeg' }, { format: 'webp' }, { format: 'avif' }]
}

export default function imagesetPlugin(opts?: Options) {
const pluginOptions = Object.assign({}, defaultOptions, opts)
const filter = createFilter(pluginOptions.include, pluginOptions.exclude)

let config: Record<string, any>

return {
name: 'imageset',
enforce: 'pre',

configResolved(resolvedConfig) {
config = resolvedConfig
pluginOptions.cachePath ??= path.join(config.optimizeCacheDir, 'imageset_cache')
},

async load(id: string) {
if (!filter(id)) {
return null
} else {
const paths = await Promise.all(
pluginOptions.targets.map(target => {
if (config.command === 'serve') {
return transformDev(id, target)
import { promises as fs } from 'fs'
import { dataToEsm } from "rollup-pluginutils"

export default function () {
const filter = pm(['**/*.jpg', '**/*.jpg', '**/*.png', '**/*.webp', '**/*.webp', '**/*.avif', '**/*.gif', '**/*.heif'])

const CACHE_DIR = './node_modules/.cache/vite-plugin-imageset'

let globalConfig

return {
name: 'imageset',
enforce: 'pre' as 'pre',
async buildStart() {
await fs.mkdir(CACHE_DIR, { recursive: true })
},
configResolved(config) {
globalConfig = config
},
async load(assetPath: string) {
const url = new URL(assetPath, 'file://')

if (!filter(url.pathname)) {
return null
} else {
return transformBuild(id, target, this)
const options = buildOptions(url, directives())
const id = generateId(assetPath)
const ext = options.format ? `.${options.format}` : path.extname(url.pathname)
const cachePath = path.join(CACHE_DIR, id + ext)

if (globalConfig.command === 'serve') {
if (!(await isCached(cachePath))) {
const content = await transformImage(url, options)
await fs.writeFile(cachePath, content)
}

return dataToEsm('/' + path.relative(globalConfig.root, cachePath), globalConfig.build.rollupOptions)
} else {
let content: Uint8Array
if (await isCached(cachePath)) {
content = await fs.readFile(cachePath)
} else {
content = await transformImage(url, options)
await fs.writeFile(cachePath, content)
}

const file = url.pathname

const fileHandle = this.emitFile({
name: `${path.basename(file, path.extname(file))}${ext}`,
type: 'asset',
source: content
})

return dataToEsm(`__VITE_ASSET__${fileHandle}__`, globalConfig.build.rollupOptions)
}

}
})
)

const out = paths.reduce(
(prev, { src, target }) => ({
...prev,
[target.format]: src
}),
{}
)

return dataToEsm(out, { namedExports: false })
}
},

renderChunk(code: string) {
let match: RegExpExecArray
let s: MagicString
while ((match = assetUrlQuotedRE.exec(code))) {
s = s || (s = new MagicString(code))
const [full, fileHandle, postfix = ''] = match

const outputFilepath = config.base + this.getFileName(fileHandle) + postfix
s.overwrite(match.index, match.index + full.length, JSON.stringify(outputFilepath))
}
if (s) {
return {
code: s.toString(),
map: config.build.sourcemap ? s.generateMap({ hires: true }) : null
}
} else {
return null
}
}
}
}

async function transformDev(sourceId: string, target: Target) {
const id = transformId(sourceId, target)
const info = await cacache.get.info(pluginOptions.cachePath, id)
async function isCached(path: string) {
try {
const stat = await fs.stat(path)
return stat.isFile()
} catch {
return false
}

if (info) {
return { src: '/' + path.relative(config.root, info.path), target }
} else {
const content = await transformFile(sourceId, target)
await cacache.put(pluginOptions.cachePath, id, content)
const info = await cacache.get.info(pluginOptions.cachePath, id)
}

return { src: '/' + path.relative(config.root, info.path), target }
}
}

async function transformBuild(sourceId: string, target: Target, ctx: PluginContext) {
const id = transformId(sourceId, target)
const info = await cacache.get.info(pluginOptions.cachePath, id)

let content: Uint8Array
if (info) {
const { data } = await cacache.get(pluginOptions.cachePath, id)
content = data
} else {
content = await transformFile(sourceId, target)
await cacache.put(pluginOptions.cachePath, id, content)
}
function transformImage(url: URL, options: Options) {
let pipeline = sharp(url.pathname)

const file = cleanUrl(sourceId)
const { search, hash } = new URL(sourceId)
const postfix = (search || '') + (hash || '')

const fileHandle = ctx.emitFile({
name: `${path.basename(file, path.extname(file))}.${target.format}`,
type: 'asset',
source: content
})

return { src: `__IMAGESET__${fileHandle}__${postfix ? `${postfix}__` : ``}`, target }
}

async function transformFile(id: string, target: Target) {
// load the file
let img = sharp(id)
// resize if necessary
if (target.width || target.height) {
img = img.resize(target)
if (has(options, 'size', 'width', 'height')) {
pipeline = pipeline.resize(options)
}
// transform
if (target.transform) {
img = target.transform(img, target.format)
if (has(options, 'format')) {
pipeline = pipeline.toFormat(options.format)
}
// transcode
img = img[target.format](target)
// return
return img.toBuffer()
}

return pipeline.toBuffer()
}

function generateId(id: string) {
return createHash('sha1').update(id).digest('hex').slice(0, 16)
}
22 changes: 22 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AvifOptions, GifOptions, JpegOptions, PngOptions, ResizeOptions, Sharp, TiffOptions, WebpOptions, FormatEnum } from "sharp";
import { Directive } from "./directives";

export interface Options extends ResizeOptions, JpegOptions, WebpOptions, PngOptions, AvifOptions, GifOptions, TiffOptions {
format: keyof FormatEnum
}

export function buildOptions(url: URL, directives: Directive[]): Options {
const parts = Array.from(url.searchParams.entries()).map(([dirName, argument]) => {
const directive = directives.find(dir => dir.test(dirName, argument))

if (!directive) throw new Error('unknown directive ' + dirName)
return directive.transform
? directive.transform(dirName, argument)
: { [dirName]: argument }
})
return Object.assign({}, ...parts)
}

export function has(options: Options, ...keys: string[]) {
return keys.some((key) => key in options)
}
Loading

0 comments on commit 0f1b5b1

Please sign in to comment.