mockaton
Version:
HTTP Mock Server
993 lines (873 loc) • 24.1 kB
JavaScript
import { DEFAULT_500_COMMENT, HEADER_FOR_502 } from './ApiConstants.js'
import { parseFilename, extractComments } from './Filename.js'
import { Commander } from './ApiCommander.js'
const Strings = {
bulk_select: 'Bulk Select',
bulk_select_disabled_title: 'No mock files have comments, which are anything within parentheses on the filename.',
click_link_to_preview: 'Click a link to preview it',
cookie: 'Cookie',
cookie_disabled_title: 'No cookies specified in config.cookies',
delay: 'Delay',
delay_ms: 'Delay (ms)',
empty_response_body: '/* Empty Response Body */',
fallback_server: 'Fallback',
fallback_server_error: '⛔ Fallback Backend Error',
fallback_server_placeholder: 'Type Backend Address',
fetching: 'Fetching…',
got: 'Got',
group_by_method: 'Group by Method',
internal_server_error: 'Internal Server Error',
no_mocks_found: 'No mocks found',
not_found: 'Not Found',
pick_comment: 'Pick Comment…',
preview: 'Preview',
proxied: 'Proxied',
proxy_toggler: 'Proxy Toggler',
reset: 'Reset',
save_proxied: 'Save Mocks',
static_get: 'Static GET',
title: 'Mockaton'
}
const CSS = {
BulkSelector: null,
DelayToggler: null,
ErrorToast: null,
FallbackBackend: null,
Field: null,
GlobalDelayField: null,
GroupByMethod: null,
InternalServerErrorToggler: null,
MenuTrigger: null,
MockList: null,
MockSelector: null,
NotFoundToggler: null,
PayloadViewer: null,
PreviewLink: null,
ProgressBar: null,
ProxyToggler: null,
ResetButton: null,
SaveProxiedCheckbox: null,
StaticFilesList: null,
chosen: null,
dittoDir: null,
empty: null,
leftSide: null,
nonDefault: null,
red: null,
rightSide: null,
status4xx: null,
json: null,
syntaxKey: null,
syntaxStr: null,
syntaxVal: null,
syntaxAttr: null,
syntaxAttrVal: null,
syntaxTag: null,
syntaxPunc: null
}
for (const k of Object.keys(CSS))
CSS[k] = k
/** @type {State & {
* groupByMethod: boolean,
* canProxy: boolean
* }} */
const state = {
brokersByMethod: {},
staticBrokers: {},
cookies: [],
comments: [],
delay: 0,
collectProxied: false,
proxyFallback: '',
groupByMethod: true, // TODO read from localstorage
get canProxy() {
return Boolean(this.proxyFallback)
}
}
const mockaton = new Commander(window.location.origin)
updateState()
initLongPoll()
async function updateState() {
try {
const response = await mockaton.getState()
if (!response.ok)
throw response.status
Object.assign(state, await response.json())
document.body.replaceChildren(...App())
}
catch (error) {
onError(parseError(error))
}
}
const r = createElement
const s = createSvgElement
function App() {
return [
r(Header),
r(Menu),
r('main', null,
r('div', className(CSS.leftSide),
r(MockList),
r(StaticFilesList)),
r('div', className(CSS.rightSide),
r(PayloadViewer)))
]
}
function Header() {
return (
r('header', null,
r('img', {
alt: Strings.title,
src: 'mockaton/Logo.svg',
width: 160
}),
r('div', null,
r(GlobalDelayField),
r(CookieSelector),
r(BulkSelector),
r(ProxyFallbackField),
r(ResetButton)),
r('button', {
className: CSS.MenuTrigger,
popovertarget: 'Menu'
}, r(MenuIcon))
))
}
function Menu() {
return (
r('menu', {
id: 'Menu',
popover: ''
},
r('label', className(CSS.GroupByMethod),
r('input', {
type: 'checkbox',
checked: state.groupByMethod,
onChange() {
state.groupByMethod = !state.groupByMethod
updateState()
// TODO localstorage
}
}),
r('span', null, Strings.group_by_method)),
r('a', {
href: 'https://github.com/ericfortis/mockaton',
target: '_blank',
rel: 'noopener noreferrer'
}, 'Documentation')
))
}
function CookieSelector() {
const { cookies } = state
function onChange() {
mockaton.selectCookie(this.value)
.then(parseError)
.catch(onError)
}
const disabled = cookies.length <= 1
return (
r('label', className(CSS.Field),
r('span', null, Strings.cookie),
r('select', {
autocomplete: 'off',
disabled,
title: disabled ? Strings.cookie_disabled_title : '',
onChange
}, cookies.map(([value, selected]) =>
r('option', { value, selected }, value)))))
}
function BulkSelector() {
const { comments } = state
// UX wise this should be a menu instead of this `select`.
// But this way is easier to implement, with a few hacks.
const firstOption = Strings.pick_comment
function onChange() {
const value = this.value
this.value = firstOption // Hack
mockaton.bulkSelectByComment(value)
.then(parseError)
.then(updateState)
.catch(onError)
}
const disabled = !comments.length
const list = disabled
? []
: [firstOption].concat(comments)
return (
r('label', className(CSS.Field),
r('span', null, Strings.bulk_select),
r('select', {
className: CSS.BulkSelector,
'data-qaid': 'BulkSelector',
autocomplete: 'off',
disabled,
title: disabled ? Strings.bulk_select_disabled_title : '',
onChange
}, list.map(value =>
r('option', { value }, value)))))
}
function GlobalDelayField() {
const { delay } = state
function onChange() {
state.delay = this.valueAsNumber
mockaton.setGlobalDelay(state.delay)
.then(parseError)
.catch(onError)
}
return (
r('label', className(CSS.Field, CSS.GlobalDelayField),
r('span', null, r(TimerIcon), Strings.delay_ms),
r('input', {
type: 'number',
min: 0,
step: 100,
autocomplete: 'none',
value: delay,
onChange
})))
}
function ProxyFallbackField() {
const { proxyFallback, collectProxied } = state
function onChange() {
const saveCheckbox = this.closest(`.${CSS.FallbackBackend}`).querySelector('[type=checkbox]')
saveCheckbox.disabled = !this.validity.valid || !this.value.trim()
if (!this.validity.valid)
this.reportValidity()
else
mockaton.setProxyFallback(this.value.trim())
.then(parseError)
.then(updateState)
.catch(onError)
}
return (
r('div', className(CSS.Field, CSS.FallbackBackend),
r('label', null,
r('span', null, r(CloudIcon), Strings.fallback_server),
r('input', {
type: 'url',
autocomplete: 'none',
placeholder: Strings.fallback_server_placeholder,
value: proxyFallback,
onChange
})),
r(SaveProxiedCheckbox, {
collectProxied,
disabled: !proxyFallback
})))
}
function SaveProxiedCheckbox({ disabled }) {
const { collectProxied } = state
function onChange() {
mockaton.setCollectProxied(this.checked)
.then(parseError)
.catch(onError)
}
return (
r('label', className(CSS.SaveProxiedCheckbox),
r('input', {
type: 'checkbox',
disabled,
checked: collectProxied,
onChange
}),
r('span', null, Strings.save_proxied)))
}
function ResetButton() {
function onClick() {
mockaton.reset()
.then(parseError)
.then(updateState)
.catch(onError)
}
return (
r('button', {
className: CSS.ResetButton,
onClick
}, Strings.reset))
}
/** # MockList */
function MockList() {
const { brokersByMethod, groupByMethod } = state
const canProxy = state.canProxy
if (!Object.keys(brokersByMethod).length)
return (
r('div', className(CSS.empty),
Strings.no_mocks_found))
if (groupByMethod)
return (
r('div', null,
r('table', null, Object.keys(brokersByMethod).map(method =>
r('tbody', null,
r('tr', null,
r('th', { colspan: 2 + Number(canProxy) }),
r('th', null, method)),
rowsFor(method).map(Row))))))
return (
r('div', null,
r('table', null,
r('tbody', null, rowsFor('*').map(Row)))))
}
function Row({ method, urlMask, urlMaskDittoed, broker }) {
const canProxy = state.canProxy
return (
r('tr', { 'data-method': method, 'data-urlMask': urlMask },
canProxy && r('td', null, r(ProxyToggler, { broker })),
r('td', null, r(DelayRouteToggler, { broker })),
r('td', null, r(InternalServerErrorToggler, { broker })),
r('td', null, r(PreviewLink, { method, urlMask, urlMaskDittoed })),
r('td', null, r(MockSelector, { broker }))))
}
function rowsFor(targetMethod) {
const { brokersByMethod } = state
const rows = []
for (const [method, brokers] of Object.entries(brokersByMethod))
if (targetMethod === '*' || targetMethod === method)
for (const [urlMask, broker] of Object.entries(brokers))
rows.push({ method, urlMask, broker })
const sorted = rows
.filter((r) => r.broker.mocks.length > 1) // >1 because of autogen500
.sort((rA, rB) => rA.urlMask.localeCompare(rB.urlMask))
const urlMasksDittoed = dittoSplitPaths(sorted.map(r => r.urlMask))
return sorted.map((r, i) => ({
...r,
urlMaskDittoed: urlMasksDittoed[i]
}))
}
function PreviewLink({ method, urlMask, urlMaskDittoed }) {
async function onClick(event) {
event.preventDefault()
try {
document.querySelector(`.${CSS.PreviewLink}.${CSS.chosen}`)?.classList.remove(CSS.chosen)
this.classList.add(CSS.chosen)
await previewMock(method, urlMask, this.href)
}
catch (error) {
onError(error)
}
}
const [ditto, tail] = urlMaskDittoed
return (
r('a', {
className: CSS.PreviewLink,
href: urlMask,
onClick
}, ditto
? [r('span', className(CSS.dittoDir), ditto), tail]
: tail))
}
/** @param {{ broker: ClientMockBroker }} props */
function MockSelector({ broker }) {
const { groupByMethod } = state
function onChange() {
const { urlMask, method } = parseFilename(this.value)
mockaton.select(this.value)
.then(parseError)
.then(updateState)
.then(() => linkFor(method, urlMask)?.click())
.catch(onError)
}
let selected = broker.currentMock.file
const { status, urlMask } = parseFilename(selected)
const files = broker.mocks.filter(item =>
status === 500 ||
!item.includes(DEFAULT_500_COMMENT))
if (!selected) {
selected = Strings.proxied
files.push(selected)
}
function nameFor(file) {
if (file === Strings.proxied)
return Strings.proxied
const { status, ext, method } = parseFilename(file)
return groupByMethod
? `${status} ${ext} ${extractComments(file).join(' ')}`
: `${method} ${status} ${ext} ${extractComments(file).join(' ')}`
}
return (
r('select', {
onChange,
autocomplete: 'off',
'data-qaid': urlMask,
disabled: files.length <= 1,
...className(
CSS.MockSelector,
selected !== files[0] && CSS.nonDefault,
status >= 400 && status < 500 && CSS.status4xx)
}, files.map(file => (
r('option', {
value: file,
selected: file === selected
}, nameFor(file))))))
}
/** @param {{ broker: ClientMockBroker }} props */
function DelayRouteToggler({ broker }) {
function onChange() {
const { method, urlMask } = parseFilename(broker.mocks[0])
mockaton.setRouteIsDelayed(method, urlMask, this.checked)
.then(parseError)
.catch(onError)
}
return (
r('label', {
className: CSS.DelayToggler,
title: Strings.delay
},
r('input', {
type: 'checkbox',
checked: broker.currentMock.delayed,
onChange
}),
TimerIcon()))
}
/** @param {{ broker: ClientMockBroker }} props */
function InternalServerErrorToggler({ broker }) {
function onChange() {
const { urlMask, method } = parseFilename(broker.mocks[0])
mockaton.select(
this.checked
? broker.mocks.find(f => parseFilename(f).status === 500)
: broker.mocks[0])
.then(parseError)
.then(updateState)
.then(() => linkFor(method, urlMask)?.click())
.catch(onError)
}
return (
r('label', {
className: CSS.InternalServerErrorToggler,
title: Strings.internal_server_error
},
r('input', {
type: 'checkbox',
name: broker.currentMock.file,
checked: parseFilename(broker.currentMock.file).status === 500,
onChange
}),
r('span', null, '500')))
}
/** @param {{ broker: ClientMockBroker }} props */
function ProxyToggler({ broker }) {
function onChange() {
const { urlMask, method } = parseFilename(broker.mocks[0])
mockaton.setRouteIsProxied(method, urlMask, this.checked)
.then(parseError)
.then(updateState)
.then(() => linkFor(method, urlMask)?.click())
.catch(onError)
}
return (
r('label', {
className: CSS.ProxyToggler,
title: Strings.proxy_toggler
},
r('input', {
type: 'checkbox',
checked: !broker.currentMock.file,
onChange
}),
r(CloudIcon)))
}
/** # StaticFilesList */
function StaticFilesList() {
const { staticBrokers } = state
const canProxy = state.canProxy
if (!Object.keys(staticBrokers).length)
return null
const dp = dittoSplitPaths(Object.keys(staticBrokers)).map(([ditto, tail]) => ditto
? [r('span', className(CSS.dittoDir), ditto), tail]
: tail)
return (
r('table', className(CSS.StaticFilesList),
r('thead', null,
r('tr', null,
r('th', { colspan: 2 + Number(canProxy) }),
r('th', null, Strings.static_get))),
r('tbody', null,
Object.values(staticBrokers).map((broker, i) =>
r('tr', null,
canProxy && r('td', null, r(ProxyStaticToggler, {})),
r('td', null, r(DelayStaticRouteToggler, { broker })),
r('td', null, r(NotFoundToggler, { broker })),
r('td', null, r('a', { href: broker.route, target: '_blank' }, dp[i]))
)))))
}
/** @param {{ broker: ClientStaticBroker }} props */
function DelayStaticRouteToggler({ broker }) {
function onChange() {
mockaton.setStaticRouteIsDelayed(broker.route, this.checked)
.then(parseError)
.catch(onError)
}
return (
r('label', {
className: CSS.DelayToggler,
title: Strings.delay
},
r('input', {
type: 'checkbox',
checked: broker.delayed,
onChange
}),
TimerIcon()))
}
/** @param {{ broker: ClientStaticBroker }} props */
function NotFoundToggler({ broker }) {
function onChange() {
mockaton.setStaticRouteStatus(broker.route, this.checked ? 404 : 200)
.then(parseError)
.catch(onError)
}
return (
r('label', {
className: CSS.NotFoundToggler,
title: Strings.not_found
},
r('input', {
type: 'checkbox',
checked: broker.status === 404,
onChange
}),
r('span', null, '404')))
}
function ProxyStaticToggler({}) { // TODO
function onChange() {
}
return (
r('label', {
style: { visibility: 'hidden' },
className: CSS.ProxyToggler,
title: Strings.proxy_toggler
},
r('input', {
type: 'checkbox',
disabled: true,
onChange
}),
r(CloudIcon)))
}
/** # Payload Preview */
const payloadViewerTitleRef = useRef()
const payloadViewerRef = useRef()
function PayloadViewer() {
return (
r('div', className(CSS.PayloadViewer),
r('h2', { ref: payloadViewerTitleRef }, Strings.preview),
r('pre', null,
r('code', { ref: payloadViewerRef }, Strings.click_link_to_preview))))
}
function PayloadViewerProgressBar() {
return (
r('div', className(CSS.ProgressBar),
r('div', { style: { animationDuration: state.delay + 'ms' } })))
}
function PayloadViewerTitle({ file, status, statusText }) {
const { urlMask, method, ext } = parseFilename(file)
return (
r('span', null,
urlMask.replace(/^\//, '') + '.' + method + '.',
r('abbr', { title: statusText }, status),
'.' + ext))
}
function PayloadViewerTitleWhenProxied({ mime, status, statusText, gatewayIsBad }) {
return (
r('span', null,
gatewayIsBad
? r('span', className(CSS.red), Strings.fallback_server_error + ' ')
: r('span', null, Strings.got + ' '),
r('abbr', { title: statusText }, status),
' ' + mime))
}
async function previewMock(method, urlMask, href) {
previewMock.controller?.abort()
previewMock.controller = new AbortController
payloadViewerTitleRef.current.replaceChildren(r('span', null, Strings.fetching))
payloadViewerRef.current.replaceChildren(PayloadViewerProgressBar())
try {
const response = await fetch(href, {
method,
signal: previewMock.controller.signal
})
await updatePayloadViewer(method, urlMask, response)
}
catch (err) {
onError(err)
payloadViewerRef.current.replaceChildren()
}
}
async function updatePayloadViewer(method, urlMask, response) {
const mime = response.headers.get('content-type') || ''
const file = mockSelectorFor(method, urlMask).value
if (file === Strings.proxied)
payloadViewerTitleRef.current.replaceChildren(PayloadViewerTitleWhenProxied({
status: response.status,
statusText: response.statusText,
mime,
gatewayIsBad: response.headers.get(HEADER_FOR_502)
}))
else
payloadViewerTitleRef.current.replaceChildren(PayloadViewerTitle({
status: response.status,
statusText: response.statusText,
file
}))
if (mime.startsWith('image/')) { // Naively assumes GET.200
payloadViewerRef.current.replaceChildren(
r('img', {
src: URL.createObjectURL(await response.blob())
}))
}
else {
const body = await response.text() || Strings.empty_response_body
if (mime === 'application/json')
payloadViewerRef.current.replaceChildren(r('span', className(CSS.json), syntaxJSON(body)))
else if (isXML(mime))
payloadViewerRef.current.replaceChildren(syntaxXML(body))
else
payloadViewerRef.current.innerText = body
}
}
function isXML(mime) {
return ['text/html', 'text/xml', 'application/xml'].includes(mime)
|| /application\/.*\+xml/.test(mime)
}
function trFor(method, urlMask) {
return document.querySelector(`tr[data-method="${method}"][data-urlMask="${urlMask}"]`)
}
function linkFor(method, urlMask) {
return trFor(method, urlMask)?.querySelector(`a.${CSS.PreviewLink}`)
}
function mockSelectorFor(method, urlMask) {
return trFor(method, urlMask)?.querySelector(`select.${CSS.MockSelector}`)
}
/** # Misc */
async function parseError(response) {
if (response.ok)
return
if (response.status === 422)
throw await response.text()
throw response.statusText
}
function onError(error) {
if (error?.name === 'AbortError')
return
if (error?.message === 'Failed to fetch')
showErrorToast('Looks like the Mockaton server is not running')
else
showErrorToast(error || 'Unexpected Error')
console.error(error)
}
function showErrorToast(msg) {
document.getElementsByClassName(CSS.ErrorToast)[0]?.remove()
document.body.appendChild(
r('div', {
className: CSS.ErrorToast,
onClick() {
const toast = this
document.startViewTransition(() => toast.remove())
}
}, msg))
}
function TimerIcon() {
return (
s('svg', { viewBox: '0 0 24 24' },
s('path', { d: 'm11 5.6 0.14 7.2 6 3.7' })))
}
function CloudIcon() {
return (
s('svg', { viewBox: '0 0 24 24' },
s('path', { d: 'm6.1 8.9c0.98-2.3 3.3-3.9 6-3.9 3.3-2e-7 6 2.5 6.4 5.7 0.018 0.15 0.024 0.18 0.026 0.23 0.0016 0.037 8.2e-4 0.084 0.098 0.14 0.097 0.054 0.29 0.05 0.48 0.05 2.2 0 4 1.8 4 4s-1.8 4-4 4c-4-0.038-9-0.038-13-0.018-2.8 0-5-2.2-5-5-2.2e-7 -2.8 2.2-5 5-5 2.8 2e-7 5 2.2 5 5' }),
s('path', { d: 'm6.1 9.1c2.8 0 5 2.3 5 5' })))
}
function MenuIcon() {
return (
s('svg', { viewBox: '0 0 24 24' },
s('path', { d: 'M3 18h18v-2H3zm0-5h18v-2H3zm0-7v2h18V6z' })))
}
/**
* # Poll UI Sync Version
* The version increments when a mock file is added or removed
*/
function initLongPoll() {
poll.oldSyncVersion = 0
poll.controller = new AbortController()
poll()
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
poll.controller.abort('_hidden_tab_')
poll.controller = new AbortController()
}
else
poll()
})
}
async function poll() {
try {
const response = await mockaton.getSyncVersion(poll.oldSyncVersion, poll.controller.signal)
if (response.ok) {
const syncVersion = await response.json()
if (poll.oldSyncVersion !== syncVersion) { // because it could be < or >
poll.oldSyncVersion = syncVersion
await updateState()
}
poll()
}
else
throw response.status
}
catch (error) {
if (error !== '_hidden_tab_')
setTimeout(poll, 3000)
}
}
/** # Utils */
function className(...args) {
return {
className: args.filter(Boolean).join(' ')
}
}
function createElement(tag, props, ...children) {
if (typeof tag === 'function')
return tag(props)
const node = document.createElement(tag)
for (const [k, v] of Object.entries(props || {}))
if (k === 'ref') v.current = node
else if (k === 'style') Object.assign(node.style, v)
else if (k.startsWith('on')) node.addEventListener(k.replace(/^on/, '').toLowerCase(), v)
else if (k in node) node[k] = v
else node.setAttribute(k, v)
node.append(...children.flat().filter(Boolean))
return node
}
function createSvgElement(tagName, props, ...children) {
const elem = document.createElementNS('http://www.w3.org/2000/svg', tagName)
for (const [k, v] of Object.entries(props))
elem.setAttribute(k, v)
elem.append(...children.flat().filter(Boolean))
return elem
}
function useRef() {
return { current: null }
}
/**
* Think of this as a way of printing a directory tree in which
* the repeated folder paths are kept but styled differently.
* @param {string[]} paths - sorted
*/
function dittoSplitPaths(paths) {
const result = [['', paths[0]]]
const pathsInParts = paths.map(p => p.split('/').filter(Boolean))
for (let i = 1; i < paths.length; i++) {
const prevParts = pathsInParts[i - 1]
const currParts = pathsInParts[i]
let j = 0
while (
j < currParts.length &&
j < prevParts.length &&
currParts[j] === prevParts[j])
j++
if (!j) // no common dirs
result.push(['', paths[i]])
else {
const ditto = '/' + currParts.slice(0, j).join('/') + '/'
result.push([ditto, paths[i].slice(ditto.length)])
}
}
return result
}
(function testDittoSplitPaths() {
const input = [
'/api/user',
'/api/user/avatar',
'/api/user/friends',
'/api/vid',
'/api/video/id',
'/api/video/stats',
'/v2/foo',
'/v2/foo/bar'
]
const expected = [
['', '/api/user'],
['/api/user/', 'avatar'],
['/api/user/', 'friends'],
['/api/', 'vid'],
['/api/', 'video/id'],
['/api/video/', 'stats'],
['', '/v2/foo'],
['/v2/foo/', 'bar']
]
console.assert(JSON.stringify(dittoSplitPaths(input)) === JSON.stringify(expected))
}())
function syntaxJSON(json) {
const MAX_NODES = 1000
let nNodes = 0
const frag = document.createDocumentFragment()
function span(className, textContent) {
nNodes++
const s = document.createElement('span')
s.className = className
s.textContent = textContent
frag.appendChild(s)
}
function text(t) {
nNodes++
frag.appendChild(document.createTextNode(t))
}
let match
let lastIndex = 0
syntaxJSON.regex.lastIndex = 0 // resets regex
while ((match = syntaxJSON.regex.exec(json)) !== null) {
if (nNodes > MAX_NODES)
break
if (match.index > lastIndex)
text(json.slice(lastIndex, match.index))
const [full, str, colon, punc] = match
lastIndex = match.index + full.length
if (str && colon) {
span(CSS.syntaxKey, str)
text(colon)
}
else if (punc) text(punc)
else if (str) span(CSS.syntaxStr, str)
else span(CSS.syntaxVal, full)
}
frag.normalize()
text(json.slice(lastIndex))
return frag
}
syntaxJSON.regex = /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*")(\s*:)?|([{}\[\],:\s]+)|\S+/g
// Capture group order: [string, optional colon, punc]
function syntaxXML(xml) {
const MAX_NODES = 1000
let nNodes = 0
const frag = document.createDocumentFragment()
function span(className, textContent) {
nNodes++
const s = document.createElement('span')
s.className = className
s.textContent = textContent
frag.appendChild(s)
}
function text(t) {
nNodes++
frag.appendChild(document.createTextNode(t))
}
let match
let lastIndex = 0
syntaxXML.regex.lastIndex = 0
while ((match = syntaxXML.regex.exec(xml)) !== null) {
if (nNodes > MAX_NODES)
break
if (match.index > lastIndex)
text(xml.slice(lastIndex, match.index))
lastIndex = match.index + match[0].length
if (match[1]) span(CSS.syntaxPunc, match[1])
else if (match[2]) span(CSS.syntaxTag, match[2])
else if (match[3]) span(CSS.syntaxAttr, match[3])
else if (match[4]) span(CSS.syntaxAttrVal, match[4])
}
text(xml.slice(lastIndex))
frag.normalize()
return frag
}
syntaxXML.regex = /(<\/?|\/?>|\?>)|(?<=<\??\/?)([A-Za-z_:][\w:.-]*)|([A-Za-z_:][\w:.-]*)(?==)|("(?:[^"\\]|\\.)*")/g
// Capture groups order: [tagPunc, tagName, attrName, attrVal]