-
Notifications
You must be signed in to change notification settings - Fork 0
/
escape.js
158 lines (158 loc) · 4.89 KB
/
escape.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
import MagicString from 'magic-string'
import sax from 'sax'
const default_opts = {
tag: 'escape-content',
extensions: ['.svelte']
}
const parser = sax.parser(false, {
lowercase: true,
position: true
})
/**
* @see: https://github.com/tamino-martinius/node-ts-dedent (by Tamino Martinius)
* @license MIT
*/
const dedent = (templ, ...values) => {
let strings = Array.from(typeof templ === 'string' ? [templ] : templ)
// 1. Remove trailing whitespace.
strings[strings.length - 1] = strings[strings.length - 1].replace(
/\r?\n([\t ]*)$/,
''
)
// 2. Find all line breaks to determine the highest common indentation level.
const indentLengths = strings.reduce((arr, str) => {
const matches = str.match(/\n([\t ]+|(?!\s).)/g)
if (matches) {
return arr.concat(
matches.map((match) => match.match(/[\t ]/g)?.length ?? 0)
)
}
return arr
}, [])
// 3. Remove the common indentation from all strings.
if (indentLengths.length) {
const pattern = new RegExp(`\n[\t ]{${Math.min(...indentLengths)}}`, 'g')
strings = strings.map((str) => str.replace(pattern, '\n'))
}
// 4. Remove leading whitespace.
strings[0] = strings[0].replace(/^\r?\n/, '')
// 5. Perform interpolation.
let string = strings[0]
values.forEach((value, i) => {
// 5.1 Read current indentation level
const endentations = string.match(/(?:^|\n)( *)$/)
const endentation = endentations ? endentations[1] : ''
let indentedValue = value
// 5.2 Add indentation to values with multiline strings
if (typeof value === 'string' && value.includes('\n')) {
indentedValue = String(value)
.split('\n')
.map((str, i) => {
return i === 0 ? str : `${endentation}${str}`
})
.join('\n')
}
string += indentedValue + strings[i + 1]
})
return string
}
// Used to map characters to HTML & JavaScript entities.
const escapes = {
'`': '\\`',
"'": "\\'",
'/': '\\/',
'{': '\\{',
'}': '\\}'
}
// Used to match HTML entities and HTML characters.
const unescaped_regex = new RegExp(/[`'/{}]/g)
const escape = (s) => s.replace(unescaped_regex, (char) => escapes[char])
/**
* Walks the AST generated from `content`, replacing any instances of
* elements with the `tag` attribute with HTML-escaped content.
*
* @param content {string} - The component source code.
* @param filename {string} - The component filename.
*/
export const wrap = (tag, content, filename, highlight) => {
// sax expects exactly one element, so just wrap it.
const wrapped = '<div>' + content + '</div>'
const s = new MagicString(content)
// Start and end positions for replacement of re-built nodes.
let start
let end
// Start and end positions of node text content.
let content_start
let content_end
let current_tag = null
// Tracks the current node's tag name, since sax either only uppercases
// or lowercases the tag names during parsing.
let tag_name = null
parser.onerror = (err) => {
throw err
}
parser.onopentag = (node) => {
if (node.attributes[tag] === '' || node.attributes[tag] === tag) {
if (current_tag !== null) {
// We don't handle escaped elements that are nested.
return
}
current_tag = parser.tag
tag_name = wrapped.substring(
parser.startTagPosition,
parser.startTagPosition + parser.tag.name.length
)
start = parser.startTagPosition - 1 - 5
content_start = parser.position
}
}
parser.onclosetag = () => {
if (parser.tag === current_tag) {
let { name, attributes } = parser.tag
name = tag_name
tag_name = null
current_tag = null
end = parser.position - 5
content_end = parser.startTagPosition - 1
let content = dedent(wrapped.substring(content_start, content_end))
let attrs = ''
for (const prop in attributes) {
if (prop !== tag) {
attrs += ` ${prop}="${attributes[prop]}"`
}
}
let text = `{\`${escape(content)}\` }`
// If the component includes a `lang` specifier, first run the highlighter
// against the code before building the node.
const lang = attributes['lang']
if (highlight && typeof lang !== 'undefined') {
content = highlight(content, lang)
text = `{@html \`${escape(content)}\` }`
}
const node_string = `<${name}${attrs}>${text}</${name}>`
s.overwrite(start, end, node_string)
}
}
parser.write(wrapped).close()
return {
code: s.toString(),
map: s.generateMap({
file: filename
})
}
}
const process = (opts) => {
opts = { ...default_opts, ...opts }
return {
markup: async ({ content, filename }) => {
const fullext = '.' + filename.split('.').slice(1).join('.')
if (!opts.extensions.includes(fullext)) {
return {
code: content
}
}
return wrap(opts.tag, content, filename, opts.highlighter)
}
}
}
export default process