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
|
|
|
import { extname, basename, dirname } from 'path' // no dot prefix |
48
|
|
|
import { createHash } from 'crypto' |
49
|
|
|
import { selectAll } from 'hast-util-select' |
50
|
|
|
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
|
|
|
export const pathJoin = (...paths: string[]):string => { |
60
|
|
|
const pathsNoSlashes = paths.map((c, i, a) => { |
61
|
|
|
c = c.startsWith('./') ? c.slice(2) : c |
62
|
|
|
c = c.startsWith('/') ? c.slice(1) : c |
63
|
|
|
c = c.endsWith('/') ? c.slice(0, -1) : c |
64
|
|
|
return c |
65
|
|
|
}) |
66
|
|
|
return pathsNoSlashes.reduce((p, c) => { |
67
|
|
|
if (c === '' || c === ' ') { |
68
|
|
|
return p |
69
|
|
|
} |
70
|
|
|
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
|
|
|
const trimmedHash = (n:number) => (b:Buffer):string => createHash('sha256').update(b).digest('hex').slice(0, n) |
83
|
|
|
|
84
|
|
|
/** |
85
|
|
|
* Merge |
86
|
|
|
* @private |
87
|
|
|
*/ |
88
|
|
|
const merge = (paths: MapppedMergeStringOrObjValFunc, fallback:ConfigMap, obj:ConfigMap) => |
89
|
|
|
Object.entries(paths) |
90
|
|
|
.reduce((acc, [prop, prepFn]) => |
91
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
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
|
|
|
export const attacher = (config?:InputConfig) => { |
196
|
|
|
const select = !config || !config.select |
197
|
|
|
? 'picture[thumbnails="true"]>img' |
198
|
|
|
: typeof config.select === 'function' |
199
|
|
|
? config.select() |
200
|
|
|
: config.select |
201
|
|
|
|
202
|
|
|
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
|
|
|
const cfg: Config = mergeConfig(defaults, config as unknown as ConfigMap) as unknown as Config |
217
|
|
|
|
218
|
|
|
// transformer |
219
|
|
|
return (tree:Node, vfile:VFile, next:UnifiedPluginCallback) => { |
220
|
|
|
const selected = selectAll(select, tree) as HastNode[] |
221
|
|
|
|
222
|
|
|
const srcsCompact = selected |
223
|
|
|
.map(node => ({ node, src: (node as HastNode).properties.src })) |
224
|
|
|
.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
|
|
|
const srcs = srcsCompact.reduce((p, _s) => { |
236
|
|
|
const s = _s as unknown as Config & HastNode |
237
|
|
|
const partOfSet = { |
238
|
|
|
breaks: s.breaks, |
239
|
|
|
types: s.types, |
240
|
|
|
widths: s.widths |
241
|
|
|
} |
242
|
|
|
|
243
|
|
|
Object.entries(s.types).forEach(([format, opts]) => { |
244
|
|
|
s.widths.forEach(width => { |
245
|
|
|
let ext = extname(s.src).slice(1) // no dot prefix |
246
|
|
|
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
|
|
|
s.data = { ...s.data, _id: s.src } |
251
|
|
|
|
252
|
|
|
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
|
|
|
return p |
274
|
|
|
}, [] as SimpleConfig[]) |
275
|
|
|
|
276
|
|
|
// console.log('B.', 'srcs', { srcs }) |
277
|
|
|
|
278
|
|
|
const vfile_srcs = isArray(vfile.srcs) ? [...vfile.srcs as SimpleConfig[], ...srcs] : srcs |
279
|
|
|
|
280
|
|
|
// console.log('C.', 'vfile_srcs', { vfile_srcs }) |
281
|
|
|
vfile.srcs = vfile_srcs |
282
|
|
|
|
283
|
|
|
// return vfile |
284
|
|
|
next(null, tree, vfile) |
285
|
|
|
} |
286
|
|
|
} |
287
|
|
|
|
288
|
|
|
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
|
|
|
|