Passed
Push — main ( 371686...50a980 )
by Eric D
01:21
created

src/index.ts   A

Complexity

Total Complexity 12
Complexity/F 0

Size

Lines of Code 346
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 12
eloc 213
mnd 12
bc 12
fnc 0
dl 0
loc 346
rs 10
bpm 0
cpm 0
noi 0
c 0
b 0
f 0
1
/* eslint-disable no-use-before-define, camelcase */
2
3
/**
4
 * @title rehype-all-the-thumbs-curate
5
 * @author Eric Moore
6
 * @summary Select DOM nodes that have images availble for thumbnailing
7
 * @description Pluck out Images, and tag the file with instructions for
8
 * other thumbnailing plugins to use.
9
 * @see https://unifiedjs.com/explore/package/hast-util-select/#support
10
 *
11
 * # Inpput/Output
12
 *
13
 * ## Implied Input (Required):
14
 *
15
 *   + HTML file with a DOM tree (can be decorate with instructions)
16
 *
17
 * ## Input Config (Optional)
18
 *
19
 *   - css selctor string
20
 *   - instructions for thumbnailing images
21
 *
22
 * ## Config Preference
23
 *
24
 *   HTML > Options
25
 *
26
 * ## Output
27
 *
28
 * -an unchanged tree (aka: vfile.contents)
29
 */
30
31
import type { Node } from 'unist'
32
import type { VFile } from 'vfile'
33
import type { PngOptions, JpegOptions, WebpOptions } from 'sharp'
34
import path from 'path'
35
import { createHash } from 'crypto'
36
import { selectAll } from 'hast-util-select'
37
const { isArray } = Array
38
39
/**
40
 * Resolve
41
 * @summary Merge path segments together
42
 * @description Take in path segments,
43
 * intelligibly  merge them together to form one path.
44
 * @todo the up path
45
 */
46
export const localResolve = (...paths: string[]):string => {
47
    const withDotsButNoSlashes = paths.map((c, i, a) => {
48
        c = c.startsWith('/') ? c.slice(1) : c
49
        c = c.endsWith('/') ? c.slice(0, -1) : c
50
        return c
51
    })
52
    const noDotDotnoSlashes = withDotsButNoSlashes.reduce((p, c) => {
53
        if (c === '' || c === ' ') {
54
            return p
55
        }
56
        if (c.startsWith('..')) {
57
            return localResolve(...[...p.slice(0, -1), c.slice(2)]).split('/')
58
        } else {
59
            return [...p, c]
60
        }
61
    }, [] as string[])
62
    return noDotDotnoSlashes.join('/')
63
}
64
65
export const trimmedHash = (n:number) => (b:Buffer) => ():string => createHash('sha256').update(b).digest('hex').slice(0, n)
66
67
/**
68
 * Merge
69
 * @private
70
 */
71
const merge = (paths: MapppedMergeStringOrObjValFunc, fallback:ConfigMap, obj:ConfigMap) =>
72
    Object.entries(paths)
73
        .reduce((acc, [prop, prepFn]) =>
74
            prop in obj
75
                ? prepFn(acc, obj[prop])
76
                : prop in fallback
77
                    ? { ...acc, [prop]: fallback[prop] }
78
                    : acc
79
        , {} as ConfigMap)
80
81
/**
82
 * Map Builder: Parse String And Swap Path
83
 * @private
84
 * @description A builder function returning a key to ƒ.transform map.
85
 * The 'look-up-key'º is mapped to a merge function.
86
 * The ƒ.merge returns a new merged object,
87
 * where the 'look-up-key'º is replaced with the writePath during merge, and the val is parsed
88
*/
89
const parseStringsAndSwapPath = (readPath:string, writePath:string) => ({ [readPath]: (a:ObjectOrStringDict, s:string) => ({ ...a, [writePath]: JSON.parse(s) }) })
90
91
/**
92
 * Map Builder: Swap Path
93
 * @private
94
 * @description A builder function returning a key to ƒ.transform map.
95
 * The 'look-up-key'º (aka: readPath) is mapped to a merge function.
96
 * The ƒ.merge function is given an accumulating merge object, and a value from one of 2 target objects depending on if its found.
97
 * The ƒ.merge returns a merged object, and all it does it replce the look-up-keyº with the writePath
98
 * and the val stays unchanged
99
 * @example
100
 * const mergeMeIn = noChange('lookForThis', 'butEventuallyMakeItThis')
101
 * console.log(mergeMeIn) // { lookForThis: (all, val) => ({...all, butEventuallyMakeItThis: val}) }
102
 */
103
const noChangeJustSwapPath = (readPath:string, writePath:string) => ({ [readPath]: (a:StringDict, s:string) => ({ ...a, [writePath]: s }) }) as MappedMergeFuncs
104
105
/**
106
 * Map Builder: Indetity
107
 * @private
108
 * @description A builder function returning a key to ƒ.transform map.
109
 * The look-up-key maps to a ƒ.merge.
110
 * The ƒ.merge (inputs: (accum obj, val)) returns a merged object where the 'look-up-key'º maps to the unchanged val
111
 */
112
const noChange = (spath:string) => ({ [spath]: (a:StringDict, s:string) => ({ ...a, [spath]: s }) }) as MappedMergeFuncs
113
114
/**
115
 * Map Builder: Parse The Val
116
 * @private
117
 * @description  A builder function returning a key to ƒ.transform map.
118
 * The returned object is merged into a configuration object used for merging objects.
119
 * The `lookup-key` maps to a ƒ.merge.
120
 */
121
const parseIfString = (spath:string) => ({
122
    [spath]: (a:ObjectOrStringDict, maybeS:string|object) =>
123
        typeof maybeS === 'string'
124
            ? { ...a, [spath]: JSON.parse(maybeS) } as ObjectOrStringDict
125
            : { ...a, [spath]: maybeS }
126
}) as MapppedMergeStringOrObjValFunc
127
128
const HASTpaths = {
129
    ...noChange('selectedBy'),
130
    ...noChangeJustSwapPath('dataSourceprefix', 'sourcePrefix'),
131
    ...noChangeJustSwapPath('dataDestbasepath', 'destBasePath'),
132
    ...noChangeJustSwapPath('dataPrefix', 'prefix'),
133
    ...noChangeJustSwapPath('dataSuffix', 'suffix'),
134
    ...parseStringsAndSwapPath('dataHashlen', 'hashlen'),
135
    ...parseStringsAndSwapPath('dataClean', 'clean'),
136
    ...parseStringsAndSwapPath('dataWidths', 'widths'),
137
    ...parseStringsAndSwapPath('dataBreaks', 'breaks'),
138
    ...({ dataAddclassnames: (a, sa) => ({ ...a, addclassnames: sa.split(' ') }) } as MappedMergeFuncs),
139
    ...({ dataTypes: (a, s) => ({ ...a, types: s.split(',').reduce((p, c) => ({ ...p, [c]: {} }), {}) }) } as Dict<FuncMergeString2Obj>)
140
} as MapppedMergeStringOrObjValFunc
141
142
const NORMpaths = {
143
    ...noChange('selectedBy'),
144
    ...noChange('sourcePrefix'),
145
    ...noChange('destBasePath'),
146
    ...noChange('prefix'),
147
    ...noChange('suffix'),
148
    ...noChange('hashlen'),
149
    ...noChange('clean'),
150
    ...noChange('addclassnames'),
151
    ...parseIfString('widths'),
152
    ...parseIfString('breaks'),
153
    ...parseIfString('types')
154
} as MapppedMergeStringOrObjValFunc
155
156
const mergeNode = (fallback:ConfigMap, ob:{properties:ConfigMap}) => merge(HASTpaths, fallback as ConfigMap, ob.properties)
157
const mergeConfig = (fallback:ConfigMap, ob:ConfigMap = {}) => merge(NORMpaths, fallback as ConfigMap, ob)
158
159
/**
160
 * @exports rehype-all-the-thumbs-curate
161
 * @description the `rehype-all-the-thumbs-curate` plugin adds a transformer to the pipeline.
162
 * @param { InboundConfig } [config] - Instructions for a Resizer Algorithm to understand the types of thumbnails desired.
163
 */
164
export const attacher = (config?:InputConfig) => {
165
    const select = !config || !config.select
166
        ? 'picture[thumbnails="true"]>img'
167
        : typeof config.select === 'function'
168
            ? config.select()
169
            : config.select
170
171
    const defaults = {
172
        selectedBy: select,
173
        sourcePrefix: '/',
174
        destBasePath: '/',
175
        hashlen: 8,
176
        clean: true,
177
        types: ({ webp: {}, jpg: {} } as object), // where the empty object implies use the default for the format
178
        breaks: [640, 980, 1020],
179
        widths: [100, 250, 450, 600],
180
        addclassnames: ['all-thumbed'],
181
        prefix: 'optim/',
182
        suffix: '-{{width}}w-{{hash}}.{{ext}}'
183
    } as ConfigMap
184
185
    const cfg: Config = mergeConfig(defaults, config as unknown as ConfigMap) as unknown as Config
186
187
    // console.log({select})
188
    // console.log(0, {cfg})
189
190
    // transformer
191
    return (tree:Node, vfile:VFile, next:UnifiedPluginCallback) => {
192
    // console.log(1,  JSON.stringify({ vfile1: vfile }, null, 2))
193
    // console.log(2,  JSON.stringify({ cfg }, null, 2))
194
195
        const selected = selectAll(select, tree) as HastNode[]
196
        // console.log( JSON.stringify({ selected }, null, 2))
197
198
        const srcsCompact = selected
199
            .map(node => ({ node, src: (node as HastNode).properties.src }))
200
            .map(({ src, node }) => ({
201
                // makes a compact config
202
                ...mergeConfig(cfg as unknown as ConfigMap, mergeNode(cfg as unknown as ConfigMap, node as HastNode)),
203
                src
204
            }))
205
206
        // console.log('plugin:curate--', {srcsCompact})
207
208
        const srcs = srcsCompact.reduce((p, _s) => {
209
            const s = _s as unknown as Config
210
            const partOfSet = {
211
                breaks: s.breaks,
212
                types: s.types,
213
                widths: s.widths
214
            }
215
216
            const accSimpleConfig = [] as SimpleConfig[]
217
            Object.entries(s.types).forEach(([format, opts]) => {
218
                s.widths.forEach(width => {
219
                    const ext = path.extname(s.src).slice(1) // no dot prefix
220
                    const fileName = path.basename(s.src, `.${ext}`)
221
222
                    accSimpleConfig.push({
223
                        selectedBy: s.selectedBy,
224
                        addclassnames: s.addclassnames,
225
                        input: {
226
                            ext,
227
                            fileName,
228
                            pathPrefix: s.sourcePrefix
229
                        },
230
                        output: {
231
                            width,
232
                            format: { [format]: opts } as ImageFormat,
233
                            hashlen: s.hashlen,
234
                            hash: trimmedHash(s.hashlen)
235
                        },
236
                        getReadPath: (i?: IPathData) => !i
237
                            ? localResolve(s.sourcePrefix, `${fileName}.${ext}`)
238
                            : i.render(path.resolve(s.sourcePrefix, s.src), i.data),
239
                        getWritePath: (i?: IPathData) => !i
240
                            ? localResolve(s.destBasePath, `${s.prefix}${fileName}${s.suffix}`)
241
                            : i.render(path.resolve(s.destBasePath, `${s.prefix}${fileName}${s.suffix}`), i.data),
242
                        partOfSet
243
                    })
244
                })
245
            })
246
            return [...p, ...accSimpleConfig]
247
        }, [] as SimpleConfig[])
248
249
        // prettyPrint(0, 'plugin:curate--', {srcs})
250
        const vfile_srcs = isArray(vfile.srcs) ? [...vfile.srcs as SimpleConfig[], ...srcs] : srcs
251
252
        // prettyPrint(1, 'plugin:curate--', {vfile})
253
        vfile.srcs = vfile_srcs
254
        // return vfile
255
        next(null, tree, vfile)
256
    }
257
}
258
259
export default attacher
260
261
// #region QUICK TEST
262
263
// const vfile = require('vfile')
264
// const h = require('hastscript')
265
// const vf = new vfile({contents:'<html>', path:'/test.html'})
266
// const tree = h('.foo#some-id', [
267
//   h('span', 'some text'),
268
//   h('picture', {thumbnails:"true"}, [h('img', {src:'/image.jpg'} )]),
269
//   h('input', {type: 'text', value: 'foo'}),
270
//   h('a.alpha', {class: 'bravo charlie', download: 'download'}, [])
271
// ])
272
// const a = attacher()(tree, vf, ()=>{})
273
274
// #endregion QUICK TEST
275
276
// #region interfaces
277
278
export interface HastNode extends Node {
279
  properties: Dict<ConfigValueTypes>
280
}
281
282
export interface Config extends BaseConfig{
283
  selectedBy:string
284
  src: string
285
}
286
287
export type InputConfig = Partial<BaseConfig> & {
288
  select?: string | (()=>string)
289
}
290
291
interface IPathData{
292
  render: (template:string, view:any) => string
293
  data: any,
294
}
295
296
export interface SimpleConfig{
297
  selectedBy: string
298
  addclassnames: string[]
299
  getReadPath: (i?: {data:any, render:(template:string, view:any)=>string})=> string
300
  getWritePath: (i?: {data:any, render:(template:string, view:any)=>string})=> string
301
  output:{
302
    width: number
303
    hashlen: number
304
    format: ImageFormat
305
    hash: (b:Buffer) => () => string
306
  }
307
  input:{
308
    pathPrefix: string
309
    fileName: string
310
    ext: string
311
  }
312
  partOfSet:{
313
    widths: number[]
314
    breaks: number[]
315
    types: ImageFormat
316
  }
317
}
318
319
export type ImageFormat = {jpg: JpegOptions} | {webp: WebpOptions} | {png: PngOptions}
320
export type UnifiedPluginCallback = (err: Error | null | undefined, tree: Node, vfile: VFile) => void
321
322
interface BaseConfig{
323
  sourcePrefix: string
324
  destBasePath : string
325
  widths: number[]
326
  breaks: number[]
327
  types: ImageFormat
328
  hashlen:number
329
  addclassnames: string[]
330
  prefix: string
331
  suffix: string
332
}
333
334
interface Dict<T>{[key:string]:T}
335
type ConfigValueTypes = (boolean | null | string | string [] | number | number[])
336
type ConfigMap = Dict<ConfigValueTypes>
337
type FuncMergeStringVal = (a:ConfigMap, s:string) => ConfigMap
338
type FuncMergeStrOrObjVal = (a:ConfigMap, s:ConfigValueTypes) => ConfigMap
339
340
type StringDict = Dict<string>
341
type ObjectOrStringDict = Dict<string | object >
342
343
type FuncMergeString2Obj = (a:StringDict, s:string) => ObjectOrStringDict
344
type MappedMergeFuncs = Dict<FuncMergeStringVal>
345
type MapppedMergeStringOrObjValFunc = Dict<FuncMergeStrOrObjVal>
346
347
// #endregion interfaces
348