-
Notifications
You must be signed in to change notification settings - Fork 1k
/
Copy pathextendFile.js
196 lines (177 loc) · 6.45 KB
/
extendFile.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import fs from 'fs-extra'
/**
* Convenience function to check if a file includes a particular string.
* @param {string} path File to read and search for str.
* @param {string} str The value to search for.
* @returns true if the file exists and the contents thereof include the given string, else false.
*/
export function fileIncludes(path, str) {
return fs.existsSync(path) && fs.readFileSync(path).toString().includes(str)
}
/**
* Inject code into the file at the given path.
* Use of insertComponent assumes only one of (around|within) is used, and that the component
* identified by (around|within) occurs exactly once in the file at the given path.
* Imports are added after the last redwoodjs import.
* moduleScopeLines are added after the last import.
*
* @param {string} path Path to JSX file to extend.
* @param {Object} options Configure behavior
* @param {Object} options.insertComponent Configure component-inserting behavior.
* @param {Object} options.insertComponent.name Name of component to insert.
* @param {Object|string} options.insertComponent.props Properties to pass to the inserted component.
* @param {string} options.insertComponent.around Name of the component around which the new
* component will be inserted. Mutually exclusive with insertComponent.within.
* @param {string} options.insertComponent.within Name of the component within which the new
* component will be inserted. Mutually exclusive with insertComponent.around.
* @param {string} options.insertComponent.insertBefore Content to insert before the inserted
* component.
* @param {string} options.insertComponent.insertAfter Content to insert after the inserted
* component.
* @param {Array} options.imports Import declarations to inject after the last redwoodjs import.
* @param {Array} options.moduleScopeLines Lines of code to inject after the last import statement.
* @returns Nothing; writes changes directly into the file at the given path.
*/
export function extendJSXFile(
path,
{
insertComponent: {
name = undefined,
props = undefined,
around = undefined,
within = undefined,
insertBefore = undefined,
insertAfter = undefined,
},
imports = [],
moduleScopeLines = [],
},
) {
const content = fs.readFileSync(path).toString().split('\n')
if (moduleScopeLines?.length) {
content.splice(
content.findLastIndex((l) => l.trimStart().startsWith('import')) + 1,
0,
'', // Empty string to add a newline when we .join('\n') below.
...moduleScopeLines,
)
}
if (imports?.length) {
content.splice(
content.findLastIndex((l) => l.includes('@redwoodjs')) + 1,
0,
'', // Empty string to add a newline when we .join('\n') below.
...imports,
)
}
if (name) {
insertComponent(content, {
component: name,
props,
around,
within,
insertBefore,
insertAfter,
})
}
fs.writeFileSync(path, content.filter((e) => e !== undefined).join('\n'))
}
/**
* Inject lines of code into an array of lines to wrap the specified component in a new component tag.
* Increases the indentation of newly-wrapped content by two spaces (one tab).
*
* @param {Array} content A JSX file split by newlines.
* @param {String} component Name of the component to insert.
* @param {String|Object} props Properties to pass to the new component.
* @param {String} around Name of the component around which to insert the new component. Mutually
* exclusive with within.
* @param {String} within Name of the component within which to insert the new component. Mutually
* exclusive with around.
* @param {String} insertBefore Content to insert before the inserted component.
* @param {String} insertAfter Content to insert after the inserted component.
* @returns Nothing; modifies content in place.
*/
function insertComponent(
content,
{ component, props, around, within, insertBefore, insertAfter },
) {
if ((around && within) || !(around || within)) {
throw new Error(
'Exactly one of (around | within) must be defined. Choose one.',
)
}
const target = around ?? within
const findTagIndex = (regex) => content.findIndex((line) => regex.test(line))
let open = findTagIndex(new RegExp(`([^\\S\r\n]*)<${target}\\s*(.*)\\s*>`))
let close = findTagIndex(new RegExp(`([^\\S\r\n]*)<\/${target}>`)) + 1
if (open === -1 || close === -1) {
throw new Error(`Could not find tags for ${target}`)
}
if (within) {
open++
close--
}
// Assuming close line has same indent depth.
const [, componentDepth] = content[open].match(/([^\S\r\n]*).*/)
content.splice(
open,
close - open, // "Delete" the wrapped component contents. We put it back below.
insertBefore && componentDepth + insertBefore,
componentDepth + buildOpeningTag(component, props),
// Increase indent of each now-nested tag by one tab (two spaces)
...content.slice(open, close).map((line) => ' ' + line),
componentDepth + `</${component}>`,
insertAfter && componentDepth + insertAfter,
)
}
/**
*
* @param {string} componentName Name of the component to create a tag for.
* @param {Object|string|undefined} props Properties object, or string, to pass to the tag.
* @returns A string containing a valid JSX opening tag.
*/
function buildOpeningTag(componentName, props) {
const propsString = (() => {
switch (typeof props) {
case 'undefined':
return ''
case 'object':
return objectToComponentProps(props, { raw: true }).join(' ')
case 'string':
return props
default:
throw new Error(
`Illegal argument passed for 'props'. Required: {Object | string | undefined}, got ${typeof props}`,
)
}
})()
const possibleSpace = propsString.length ? ' ' : ''
return `<${componentName}${possibleSpace}${propsString}>`
}
/**
* Transform an object to JSX props syntax
*
* @param {Record<string, any>} obj
* @param {{exclude?: string[], raw?: boolean | string[]}} options
* @returns {string[]}
*/
export function objectToComponentProps(
obj,
options = { exclude: [], raw: false },
) {
const props = []
const doRaw = (key) =>
options.raw === true ||
(Array.isArray(options.raw) && options.raw.includes(key))
for (const [key, value] of Object.entries(obj)) {
if (options.exclude && options.exclude.includes(key)) {
continue
}
if (doRaw(key)) {
props.push(`${key}={${value}}`)
} else {
props.push(`${key}="${value}"`)
}
}
return props
}