rehype-all-the-thumbs-curate
Version:
Supporting rehype-all-the-thumbs by finding elements to be processed
372 lines (327 loc) • 12.9 kB
text/typescript
/**
* @title rehype-all-the-thumbs-curate
* @author Eric Moore
* @summary Select DOM nodes that have images availble for thumbnailing
* @description Pluck out Images, and tag the file with instructions for
* other thumbnailing plugins to use.
* @see https://unifiedjs.com/explore/package/hast-util-select/#support
*
* # Inpput/Output
*
* ## Implied Input (Required):
*
* + HTML file with a DOM tree (can be decorate with instructions)
*
* ## Input Config (Optional)
*
* - css selctor string
* - instructions for thumbnailing images
*
* ## Config Preference
*
* HTML > Options
*
* ## Output
*
* -an unchanged tree (aka: vfile.contents)
*/
/* eslint-disable no-use-before-define, camelcase */
/**
* @todo rewrite with no "string path getting"
* Config For what to do with a node will come from:
* - Selection String = DOM Node > plugin config > package.json > code default
* - defaults for:
* - DOM Node Lo
* c: `html .rehype-thumbs-curate-select`
* - select str: 'picture[thumbnails="true"]>img'
* - Config Values = DOM Node > plugin config > package.json > code default
* -
*/
import type { Node } from 'unist'
import type { VFile } from 'vfile'
import type { PngOptions, JpegOptions, WebpOptions } from 'sharp'
import { extname, basename, dirname } from 'path' // no dot prefix
import { createHash } from 'crypto'
import { selectAll } from 'hast-util-select'
const { isArray } = Array
/**
* Resolve
* @summary Merge path segments together
* @description Take in path segments,
* intelligibly merge them together to form one path.
* @todo the up path
*/
export const pathJoin = (...paths: string[]):string => {
const pathsNoSlashes = paths.map((c, i, a) => {
c = c.startsWith('./') ? c.slice(2) : c
c = c.startsWith('/') ? c.slice(1) : c
c = c.endsWith('/') ? c.slice(0, -1) : c
return c
})
return pathsNoSlashes.reduce((p, c) => {
if (c === '' || c === ' ') {
return p
}
return c.startsWith('..')
? pathJoin(...[...p.slice(0, -1), c.slice(2)]).split('/')
: [...p, c]
}, [] as string[]).join('/')
}
/**
* Trimmed Hash
* @private
* @description Take in a Buffer and return a sting with length specified via N
* @param n - length of the hash to return
*/
const trimmedHash = (n:number) => (b:Buffer):string => createHash('sha256').update(b).digest('hex').slice(0, n)
/**
* Merge
* @private
*/
const merge = (paths: MapppedMergeStringOrObjValFunc, fallback:ConfigMap, obj:ConfigMap) =>
Object.entries(paths)
.reduce((acc, [prop, prepFn]) =>
prop in obj
? prepFn(acc, obj[prop])
: prop in fallback
? { ...acc, [prop]: fallback[prop] }
: acc
, {} as ConfigMap)
/**
* Map Builder: Parse String And Swap Path
* @private
* @description A builder function returning a key to ƒ.transform map.
* The 'look-up-key'º is mapped to a merge function.
* The ƒ.merge returns a new merged object,
* where the 'look-up-key'º is replaced with the writePath during merge, and the val is parsed
*/
const parseStringsAndSwapPath = (readPath:string, writePath:string) => ({ [readPath]: (a:ObjectOrStringDict, s:string) => ({ ...a, [writePath]: JSON.parse(s) }) })
/**
* Map Builder: Swap Path
* @private
* @description A builder function returning a key to ƒ.transform map.
* The 'look-up-key'º (aka: readPath) is mapped to a merge function.
* The ƒ.merge function is given an accumulating merge object, and a value from one of 2 target objects depending on if its found.
* The ƒ.merge returns a merged object, and all it does it replce the look-up-keyº with the writePath
* and the val stays unchanged
* @example
* const mergeMeIn = noChange('lookForThis', 'butEventuallyMakeItThis')
* console.log(mergeMeIn) // { lookForThis: (all, val) => ({...all, butEventuallyMakeItThis: val}) }
*/
const noChangeJustSwapPath = (readPath:string, writePath:string) => ({ [readPath]: (a:StringDict, s:string) => ({ ...a, [writePath]: s }) }) as MappedMergeFuncs
/**
* Map Builder: Indetity
* @private
* @description A builder function returning a key to ƒ.transform map.
* The look-up-key maps to a ƒ.merge.
* The ƒ.merge (inputs: (accum obj, val)) returns a merged object where the 'look-up-key'º maps to the unchanged val
*/
const noChange = (spath:string) => ({ [spath]: (a:StringDict, s:string) => ({ ...a, [spath]: s }) }) as MappedMergeFuncs
/**
* Map Builder: Parse The Val
* @private
* @description A builder function returning a key to ƒ.transform map.
* The returned object is merged into a configuration object used for merging objects.
* The `lookup-key` maps to a ƒ.merge.
*/
const parseIfString = (spath:string) => ({
[spath]: (a:ObjectOrStringDict, maybeS:string|object) =>
// typeof maybeS === 'string'
// ? { ...a, [spath]: JSON.parse(maybeS) } as ObjectOrStringDict
// :
({ ...a, [spath]: maybeS })
}) as MapppedMergeStringOrObjValFunc
const HASTpaths = {
...noChange('selectedBy'),
...noChangeJustSwapPath('dataSourceprefix', 'sourcePrefix'),
...noChangeJustSwapPath('dataDestbasepath', 'destBasePath'),
...noChangeJustSwapPath('dataPathTmpl', 'pathTmpl'),
...parseStringsAndSwapPath('dataHashlen', 'hashlen'),
...parseStringsAndSwapPath('dataClean', 'clean'),
...parseStringsAndSwapPath('dataWidths', 'widths'),
...parseStringsAndSwapPath('dataWidthratio', 'widthratio'),
...parseStringsAndSwapPath('dataBreaks', 'breaks'),
...({ dataAddclassnames: (a, sa) => ({ ...a, addclassnames: sa.split(' ') }) } as MappedMergeFuncs),
...({ dataTypes: (a, s) => ({ ...a, types: s.split(',').reduce((p, c) => ({ ...p, [c]: {} }), {}) }) } as Dict<FuncMergeString2Obj>)
} as MapppedMergeStringOrObjValFunc
const NORMpaths = {
...noChange('selectedBy'),
...noChange('sourcePrefix'),
...noChange('destBasePath'),
...noChange('filepathPrefix'),
...noChange('pathTmpl'),
...noChange('hashlen'),
...noChange('clean'),
...noChange('addclassnames'),
...parseIfString('widths'),
...parseIfString('widthatio'),
...parseIfString('breaks'),
...parseIfString('types')
} as MapppedMergeStringOrObjValFunc
/**
* @private
* @param fallback - ConfigMap
* @param ob - object with a `properties` key with a ConfigMap type
* @description Config sometimes has a data property prefixed in the Dict if its from the DOM
*/
const mergeNode = (fallback:ConfigMap, ob:{properties:ConfigMap}) => merge(HASTpaths, fallback as ConfigMap, ob.properties)
/**
* @private
* @param fallback - a config map
* @param ob - also a config map
* @description merge the config objects via the paths, target and fallback
*/
const mergeConfig = (fallback:ConfigMap, ob:ConfigMap = {}) => merge(NORMpaths, fallback as ConfigMap, ob)
/**
* @exports rehype-all-the-thumbs-curate
* @description the `rehype-all-the-thumbs-curate` plugin adds a transformer to the pipeline.
* @param { InboundConfig } [config] - Instructions for a Resizer Algorithm to understand the types of thumbnails desired.
*/
export const attacher = (config?:InputConfig) => {
const select = !config || !config.select
? 'picture[thumbnails="true"]>img'
: typeof config.select === 'function'
? config.select()
: config.select
const defaults = {
selectedBy: select,
sourcePrefix: '/',
destBasePath: '/',
hashlen: 8,
clean: true,
types: ({ webp: {}, jpg: {} } as object), // where the empty object implies use the default for the format
breaks: [640, 980, 1020],
widths: [100, 250, 450, 600],
addclassnames: ['all-thumbed'],
widthratio: 1.7778,
pathTmpl: '/optim/{{filename}}-{{width}}w-{{hash}}.{{ext}}'
} as ConfigMap
const cfg: Config = mergeConfig(defaults, config as unknown as ConfigMap) as unknown as Config
// transformer
return (tree:Node, vfile:VFile, next:UnifiedPluginCallback) => {
const selected = selectAll(select, tree) as HastNode[]
const srcsCompact = selected
.map(node => ({ node, src: (node as HastNode).properties.src }))
.map(({ src, node }) => ({
// makes a compact config
src,
...mergeConfig(
cfg as unknown as ConfigMap,
mergeNode(cfg as unknown as ConfigMap, node as HastNode) // node config goes 2nd to overrite if needed
) as ConfigMap
})) as (ConfigMap & {src: ConfigValueTypes})[]
// console.log('A.', 'srcsCompact', { srcsCompact })
const srcs = srcsCompact.reduce((p, _s) => {
const s = _s as unknown as Config & HastNode
const partOfSet = {
breaks: s.breaks,
types: s.types,
widths: s.widths
}
Object.entries(s.types).forEach(([format, opts]) => {
s.widths.forEach(width => {
let ext = extname(s.src).slice(1) // no dot prefix
ext = ext === '' ? 'Buffer' : ext
// if we can't match up src to a set of generatyed pics via postiion
// then perhaps we could set an ID of the src
s.data = { ...s.data, _id: s.src }
p.push({
selectedBy: s.selectedBy,
addclassnames: s.addclassnames,
input: {
ext,
fileName: basename(s.src, `.${ext}`),
filepathPrefix: dirname(s.src),
rawFilePath: s.src
// domPath: ''
},
output: {
width,
format: { [format]: opts } as ImageFormat,
pathTmpl: s.pathTmpl ?? cfg.pathTmpl ?? defaults.pathTmpl as string,
hash: trimmedHash(s.hashlen),
...(s?.widthRatio ? { widthRatio: s.widthRatio } : {})
},
partOfSet
})
})
})
return p
}, [] as SimpleConfig[])
// console.log('B.', 'srcs', { srcs })
const vfile_srcs = isArray(vfile.srcs) ? [...vfile.srcs as SimpleConfig[], ...srcs] : srcs
// console.log('C.', 'vfile_srcs', { vfile_srcs })
vfile.srcs = vfile_srcs
// return vfile
next(null, tree, vfile)
}
}
export default attacher
// #region QUICK TEST
// const vfile = require('vfile')
// const h = require('hastscript')
// const vf = new vfile({contents:'<html>', path:'/test.html'})
// const tree = h('.foo#some-id', [
// h('span', 'some text'),
// h('picture', {thumbnails:"true"}, [h('img', {src:'/image.jpg'} )]),
// h('input', {type: 'text', value: 'foo'}),
// h('a.alpha', {class: 'bravo charlie', download: 'download'}, [])
// ])
// const a = attacher()(tree, vf, ()=>{})
// #endregion QUICK TEST
// #region interfaces
export interface HastNode extends Node {
properties: Dict<ConfigValueTypes>
}
export interface Config extends BaseConfig{
selectedBy:string
src: string
}
export type InputConfig = Partial<BaseConfig> & {
select?: string | (()=>string)
}
export interface SimpleConfig{
selectedBy: string
addclassnames: string[]
input:{
// domPath: string
filepathPrefix: string
fileName: string
ext: string
rawFilePath:string
}
output:{
width: number
format: ImageFormat
hash: (b:Buffer) => string
pathTmpl: string
widthratio?: number
}
partOfSet:{
widths: number[]
breaks: number[]
types: ImageFormat
}
}
export type ImageFormat = {jpg: JpegOptions} | {webp: WebpOptions} | {png: PngOptions}
export type UnifiedPluginCallback = (err: Error | null | undefined, tree: Node, vfile: VFile) => void
interface BaseConfig{
widths: number[]
breaks: number[]
types: ImageFormat
hashlen: number
addclassnames: string[]
widthRatio?: number
pathTmpl?: string
}
interface Dict<T>{[key:string]:T}
type ConfigValueTypes = (boolean | null | string | string [] | number | number[])
type ConfigMap = Dict<ConfigValueTypes>
type FuncMergeStringVal = (a:ConfigMap, s:string) => ConfigMap
type FuncMergeStrOrObjVal = (a:ConfigMap, s:ConfigValueTypes) => ConfigMap
type StringDict = Dict<string>
type ObjectOrStringDict = Dict<string | object >
type FuncMergeString2Obj = (a:StringDict, s:string) => ObjectOrStringDict
type MappedMergeFuncs = Dict<FuncMergeStringVal>
type MapppedMergeStringOrObjValFunc = Dict<FuncMergeStrOrObjVal>
// #endregion interfaces