Passed
Push — main ( 54b179...1c17ab )
by Eric D
02:08
created

src/index.ts   A

Complexity

Total Complexity 16
Complexity/F 0

Size

Lines of Code 369
Function Count 0

Duplication

Duplicated Lines 0
Ratio 0 %

Test Coverage

Coverage 94.34%

Importance

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