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
|
|
|
|