Test Failed
Push — main ( 7e560b...7b5171 )
by Eric D
02:36 queued 28s
created

src/index.ts   A

Complexity

Total Complexity 12
Complexity/F 0

Size

Lines of Code 355
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 12
eloc 214
mnd 12
bc 12
fnc 0
dl 0
loc 355
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
/**
66
 * Trimmed Hash
67
 * @private
68
 * @description Take in a Buffer and return a sting with length specified via N
69
 * @param n - length of the hash to return
70
 */
71
const trimmedHash = (n:number) => (b:Buffer) => ():string => createHash('sha256').update(b).digest('hex').slice(0, n)
72
73
/**
74
 * Merge
75
 * @private
76
 */
77
const merge = (paths: MapppedMergeStringOrObjValFunc, fallback:ConfigMap, obj:ConfigMap) =>
78
    Object.entries(paths)
79
        .reduce((acc, [prop, prepFn]) =>
80
            prop in obj
81
                ? prepFn(acc, obj[prop])
82
                : prop in fallback
83
                    ? { ...acc, [prop]: fallback[prop] }
84
                    : acc
85
        , {} as ConfigMap)
86
87
/**
88
 * Map Builder: Parse String And Swap Path
89
 * @private
90
 * @description A builder function returning a key to ƒ.transform map.
91
 * The 'look-up-key'º is mapped to a merge function.
92
 * The ƒ.merge returns a new merged object,
93
 * where the 'look-up-key'º is replaced with the writePath during merge, and the val is parsed
94
*/
95
const parseStringsAndSwapPath = (readPath:string, writePath:string) => ({ [readPath]: (a:ObjectOrStringDict, s:string) => ({ ...a, [writePath]: JSON.parse(s) }) })
96
97
/**
98
 * Map Builder: Swap Path
99
 * @private
100
 * @description A builder function returning a key to ƒ.transform map.
101
 * The 'look-up-key'º (aka: readPath) is mapped to a merge function.
102
 * The ƒ.merge function is given an accumulating merge object, and a value from one of 2 target objects depending on if its found.
103
 * The ƒ.merge returns a merged object, and all it does it replce the look-up-keyº with the writePath
104
 * and the val stays unchanged
105
 * @example
106
 * const mergeMeIn = noChange('lookForThis', 'butEventuallyMakeItThis')
107
 * console.log(mergeMeIn) // { lookForThis: (all, val) => ({...all, butEventuallyMakeItThis: val}) }
108
 */
109
const noChangeJustSwapPath = (readPath:string, writePath:string) => ({ [readPath]: (a:StringDict, s:string) => ({ ...a, [writePath]: s }) }) as MappedMergeFuncs
110
111
/**
112
 * Map Builder: Indetity
113
 * @private
114
 * @description A builder function returning a key to ƒ.transform map.
115
 * The look-up-key maps to a ƒ.merge.
116
 * The ƒ.merge (inputs: (accum obj, val)) returns a merged object where the 'look-up-key'º maps to the unchanged val
117
 */
118
const noChange = (spath:string) => ({ [spath]: (a:StringDict, s:string) => ({ ...a, [spath]: s }) }) as MappedMergeFuncs
119
120
/**
121
 * Map Builder: Parse The Val
122
 * @private
123
 * @description  A builder function returning a key to ƒ.transform map.
124
 * The returned object is merged into a configuration object used for merging objects.
125
 * The `lookup-key` maps to a ƒ.merge.
126
 */
127
const parseIfString = (spath:string) => ({
128
    [spath]: (a:ObjectOrStringDict, maybeS:string|object) =>
129
        typeof maybeS === 'string'
130
            ? { ...a, [spath]: JSON.parse(maybeS) } as ObjectOrStringDict
131
            : { ...a, [spath]: maybeS }
132
}) as MapppedMergeStringOrObjValFunc
133
134
const HASTpaths = {
135
    ...noChange('selectedBy'),
136
    ...noChangeJustSwapPath('dataSourceprefix', 'sourcePrefix'),
137
    ...noChangeJustSwapPath('dataDestbasepath', 'destBasePath'),
138
    ...noChangeJustSwapPath('dataPrefix', 'prefix'),
139
    ...noChangeJustSwapPath('dataSuffix', 'suffix'),
140
    ...parseStringsAndSwapPath('dataHashlen', 'hashlen'),
141
    ...parseStringsAndSwapPath('dataClean', 'clean'),
142
    ...parseStringsAndSwapPath('dataWidths', 'widths'),
143
    ...parseStringsAndSwapPath('dataBreaks', 'breaks'),
144
    ...({ dataAddclassnames: (a, sa) => ({ ...a, addclassnames: sa.split(' ') }) } as MappedMergeFuncs),
145
    ...({ dataTypes: (a, s) => ({ ...a, types: s.split(',').reduce((p, c) => ({ ...p, [c]: {} }), {}) }) } as Dict<FuncMergeString2Obj>)
146
} as MapppedMergeStringOrObjValFunc
147
148
const NORMpaths = {
149
    ...noChange('selectedBy'),
150
    ...noChange('sourcePrefix'),
151
    ...noChange('destBasePath'),
152
    ...noChange('prefix'),
153
    ...noChange('suffix'),
154
    ...noChange('hashlen'),
155
    ...noChange('clean'),
156
    ...noChange('addclassnames'),
157
    ...parseIfString('widths'),
158
    ...parseIfString('breaks'),
159
    ...parseIfString('types')
160
} as MapppedMergeStringOrObjValFunc
161
162
/**
163
 *
164
 * @param fallback - ConfigMap
165
 * @param ob - object with a `properties` key with a ConfigMap type
166
 */
167
const mergeNode = (fallback:ConfigMap, ob:{properties:ConfigMap}) => merge(HASTpaths, fallback as ConfigMap, ob.properties)
168
169
/**
170
 *
171
 * @param fallback - a config map
172
 * @param ob - also a config map
173
 */
174
const mergeConfig = (fallback:ConfigMap, ob:ConfigMap = {}) => merge(NORMpaths, fallback as ConfigMap, ob)
175
176
/**
177
 * @exports rehype-all-the-thumbs-curate
178
 * @description the `rehype-all-the-thumbs-curate` plugin adds a transformer to the pipeline.
179
 * @param { InboundConfig } [config] - Instructions for a Resizer Algorithm to understand the types of thumbnails desired.
180
 */
181
export const attacher = (config?:InputConfig) => {
182
    const select = !config || !config.select
183
        ? 'picture[thumbnails="true"]>img'
184
        : typeof config.select === 'function'
185
            ? config.select()
186
            : config.select
187
188
    const defaults = {
189
        selectedBy: select,
190
        sourcePrefix: '/',
191
        destBasePath: '/',
192
        hashlen: 8,
193
        clean: true,
194
        types: ({ webp: {}, jpg: {} } as object), // where the empty object implies use the default for the format
195
        breaks: [640, 980, 1020],
196
        widths: [100, 250, 450, 600],
197
        addclassnames: ['all-thumbed'],
198
        prefix: 'optim/',
199
        suffix: '-{{width}}w-{{hash}}.{{ext}}'
200
    } as ConfigMap
201
202
    const cfg: Config = mergeConfig(defaults, config as unknown as ConfigMap) as unknown as Config
203
204
    // transformer
205
    return (tree:Node, vfile:VFile, next:UnifiedPluginCallback) => {
206
        const selected = selectAll(select, tree) as HastNode[]
207
208
        const srcsCompact = selected
209
            .map(node => ({ node, src: (node as HastNode).properties.src }))
210
            .map(({ src, node }) => ({
211
                // makes a compact config
212
                src,
213
                ...mergeConfig(
214
                  cfg as unknown as ConfigMap,
215
                  mergeNode(cfg as unknown as ConfigMap, node as HastNode)
216
                )
217
            }))
218
219
        const srcs = srcsCompact.reduce((p, _s) => {
220
            const s = _s as unknown as Config
221
            const partOfSet = {
222
                breaks: s.breaks,
223
                types: s.types,
224
                widths: s.widths
225
            }
226
227
            const accSimpleConfig = [] as SimpleConfig[]
228
            Object.entries(s.types).forEach(([format, opts]) => {
229
                s.widths.forEach(width => {
230
                    const ext = path.extname(s.src).slice(1) // no dot prefix
231
                    const fileName = path.basename(s.src, `.${ext}`)
232
233
                    accSimpleConfig.push({
234
                        selectedBy: s.selectedBy,
235
                        addclassnames: s.addclassnames,
236
                        input: {
237
                            ext,
238
                            fileName,
239
                            pathPrefix: s.sourcePrefix
240
                        },
241
                        output: {
242
                            width,
243
                            format: { [format]: opts } as ImageFormat,
244
                            hashlen: s.hashlen,
245
                            hash: trimmedHash(s.hashlen)
246
                        },
247
                        getReadPath: (i?: IPathData) => !i
248
                            ? localResolve(s.sourcePrefix, `${fileName}.${ext}`)
249
                            : i.render(path.resolve(s.sourcePrefix, s.src), i.data),
250
                        getWritePath: (i?: IPathData) => !i
251
                            ? localResolve(s.destBasePath, `${s.prefix}${fileName}${s.suffix}`)
252
                            : i.render(path.resolve(s.destBasePath, `${s.prefix}${fileName}${s.suffix}`), i.data),
253
                        partOfSet
254
                    })
255
                })
256
            })
257
            return [...p, ...accSimpleConfig]
258
        }, [] as SimpleConfig[])
259
260
        const vfile_srcs = isArray(vfile.srcs) ? [...vfile.srcs as SimpleConfig[], ...srcs] : srcs
261
262
        vfile.srcs = vfile_srcs
263
        // return vfile
264
        next(null, tree, vfile)
265
    }
266
}
267
268
export default attacher
269
270
// #region QUICK TEST
271
272
// const vfile = require('vfile')
273
// const h = require('hastscript')
274
// const vf = new vfile({contents:'<html>', path:'/test.html'})
275
// const tree = h('.foo#some-id', [
276
//   h('span', 'some text'),
277
//   h('picture', {thumbnails:"true"}, [h('img', {src:'/image.jpg'} )]),
278
//   h('input', {type: 'text', value: 'foo'}),
279
//   h('a.alpha', {class: 'bravo charlie', download: 'download'}, [])
280
// ])
281
// const a = attacher()(tree, vf, ()=>{})
282
283
// #endregion QUICK TEST
284
285
// #region interfaces
286
287
export interface HastNode extends Node {
288
  properties: Dict<ConfigValueTypes>
289
}
290
291
export interface Config extends BaseConfig{
292
  selectedBy:string
293
  src: string
294
}
295
296
export type InputConfig = Partial<BaseConfig> & {
297
  select?: string | (()=>string)
298
}
299
300
interface IPathData{
301
  render: (template:string, view:any) => string
302
  data: any,
303
}
304
305
export interface SimpleConfig{
306
  selectedBy: string
307
  addclassnames: string[]
308
  getReadPath: (i?: {data:any, render:(template:string, view:any)=>string})=> string
309
  getWritePath: (i?: {data:any, render:(template:string, view:any)=>string})=> string
310
  output:{
311
    width: number
312
    hashlen: number
313
    format: ImageFormat
314
    hash: (b:Buffer) => () => string
315
  }
316
  input:{
317
    pathPrefix: string
318
    fileName: string
319
    ext: string
320
  }
321
  partOfSet:{
322
    widths: number[]
323
    breaks: number[]
324
    types: ImageFormat
325
  }
326
}
327
328
export type ImageFormat = {jpg: JpegOptions} | {webp: WebpOptions} | {png: PngOptions}
329
export type UnifiedPluginCallback = (err: Error | null | undefined, tree: Node, vfile: VFile) => void
330
331
interface BaseConfig{
332
  sourcePrefix: string
333
  destBasePath : string
334
  widths: number[]
335
  breaks: number[]
336
  types: ImageFormat
337
  hashlen:number
338
  addclassnames: string[]
339
  prefix: string
340
  suffix: string
341
}
342
343
interface Dict<T>{[key:string]:T}
344
type ConfigValueTypes = (boolean | null | string | string [] | number | number[])
345
type ConfigMap = Dict<ConfigValueTypes>
346
type FuncMergeStringVal = (a:ConfigMap, s:string) => ConfigMap
347
type FuncMergeStrOrObjVal = (a:ConfigMap, s:ConfigValueTypes) => ConfigMap
348
349
type StringDict = Dict<string>
350
type ObjectOrStringDict = Dict<string | object >
351
352
type FuncMergeString2Obj = (a:StringDict, s:string) => ObjectOrStringDict
353
type MappedMergeFuncs = Dict<FuncMergeStringVal>
354
type MapppedMergeStringOrObjValFunc = Dict<FuncMergeStrOrObjVal>
355
356
// #endregion interfaces
357