<|start_filename|>source/convert.js<|end_filename|>
import { fetchDataUrlWithCache } from './fetch'

const getAbsoluteUrl = (url, originUrl) => {
  if (REGEXP_HOST_DOMAIN.exec(url)) return url // already absolute url
  const [ domain ] = REGEXP_HOST_DOMAIN.exec(originUrl) || [ '' ]
  const rawList = url[ 0 ] !== '/'
    ? [ ...originUrl.split('/').slice(0, -1), ...url.split('/') ] // concat relative url
    : [ domain.slice(0, -1), ...url.split('/').slice(1) ] // concat absolute url
  const reducedList = []
  rawList.forEach((frag) => (frag === '..' ? reducedList.pop() : reducedList.push(frag)))
  __DEV__ && console.log('[getAbsoluteUrl]', { url, domain, rawList, reducedList })
  return reducedList.join('/')
}
// match one valid domain, with port, not localhost, not IP, check https://regexr.com/3if55
const REGEXP_HOST_DOMAIN = /((https?:)?\/\/)?[-\w@:%._+~#=]{2,256}\.[a-z]{2,63}(:\d{1,5})?\//

const convertDataUrl = (urlString, originUrl) => {
  if (urlString.startsWith('data:')) return urlString // already a dataUrl
  if (urlString.startsWith('blob:')) return fetchDataUrlWithCache(urlString) // already a blobUrl

  if (originUrl) urlString = getAbsoluteUrl(urlString, originUrl)

  const regexResult = REGEXP_URL_EXTENSION.exec(urlString)
  const urlMIME = regexResult && EXTENSION_MIME_MAP[ regexResult[ 1 ].toLowerCase() ]
  __DEV__ && console.log(' - [convertDataUrl] get urlMIME:', { urlString, urlMIME })

  return urlMIME
    ? fetchDataUrlWithCache(urlString)
    : ''
}
const REGEXP_URL_EXTENSION = /\.([0-9a-z]{3,5})(?:[?#]|$)/i // check http://stackoverflow.com/questions/6582171/javascript-regex-for-matching-extracting-file-extension
const EXTENSION_MIME_MAP = {
  woff2: 'font/woff2',
  woff: 'font/woff',
  ttf: 'font/ttf',
  otf: 'font/otf',

  svg: 'image/svg+xml',

  png: 'image/png',
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  gif: 'image/gif',
  bmp: 'image/bmp',
  ico: 'image/x-icon'
}

const convertFragListWithUrlMap = async ({ urlMap = {}, fragList = [] }) => {
  for (const urlInfo of Object.values(urlMap)) {
    const { urlString, originUrl, isIgnore } = urlInfo
    urlInfo.dataUrl = isIgnore
      ? urlString
      : (await convertDataUrl(urlString, originUrl)) || ''
  }

  for (let index = 0, indexMax = fragList.length; index < indexMax; index++) {
    const frag = fragList[ index ]
    if (typeof (frag) === 'object') fragList[ index ] = frag.dataUrl
  }

  return fragList
}

export {
  getAbsoluteUrl,
  convertDataUrl,
  convertFragListWithUrlMap
}


<|start_filename|>package.json<|end_filename|>
{
  "private": true,
  "name": "dom-snapshot",
  "version": "0.3.0-dev.0",
  "description": "Get `<canvas>` from DOM string through SVG `<foreignObject>`",
  "author": "mockingbot",
  "license": "MIT",
  "keywords": [
    "snapshot",
    "DOM",
    "canvas",
    "SVG",
    "foreignObject"
  ],
  "repository": "github:mockingbot/dom-snapshot",
  "scripts": {
    "// script ======================": "",
    "script-pack": "node -r @babel/register ./script pack",
    "script-pack-test": "node -r @babel/register ./script verbose pack test",
    "script-publish": "node -r @babel/register ./script pack publish-auto",
    "script-generate-spec": "node -r @babel/register ./script/generateSpec",
    "// build =======================": "",
    "build-library": "babel ./source --out-dir ./output-gitignore/library",
    "build-library-dev": "BABEL_ENV=dev npm run build-library -- --watch",
    "build-module": "BABEL_ENV=module babel ./source --out-dir ./output-gitignore/module",
    "build-example": "node -r @babel/register ./script/webpack.example production profile",
    "build-example-dev": "node -r @babel/register ./script/webpack.example development watch",
    "// =============================": "",
    "analyze-stat": "npx webpack-bundle-analyzer .temp-gitignore/profile-stat.json example/",
    "lint": "eslint ./source/ ./script/",
    "test": "npm run script-pack-test",
    "prepack": "echo \"Error: pack with script-*\" && exit 1"
  },
  "devDependencies": {
    "@dr-js/dev": "^0.4.10",
    "@dr-js/dev-web": "^0.4.10"
  }
}


<|start_filename|>example/resource/font/index.css<|end_filename|>
@font-face {
  font-family: 'DrCoder';
  font-style: normal;
  src: url(DrCoder.ttf) format('truetype');
}

@font-face {
  font-family: 'Material Icons';
  font-style: normal;
  src: url(Material-Icons.woff2) format('woff2');
}

.material-icons {
  font-family: 'Material Icons';
  font-weight: normal;
  font-style: normal;
  font-size: 24px;
  line-height: 1;
  letter-spacing: normal;
  text-transform: none;
  display: inline-block;
  white-space: nowrap;
  word-wrap: normal;
  direction: ltr;
  -webkit-font-feature-settings: 'liga';
  -webkit-font-smoothing: antialiased;
}

@font-face {
  font-style: normal;
  font-family: 'Roboto';
  font-weight: 400;
  src: local('Roboto'), url('roboto/Regular.woff2') format('woff2'), url('roboto/Regular.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'Roboto';
  font-weight: 700;
  src: local('Roboto-Bold'), local('Roboto Bold'), url('roboto/Bold.woff2') format('woff2'), url('roboto/Bold.woff') format('woff');
}

@font-face {
  font-style: italic;
  font-family: 'Roboto';
  font-weight: 400;
  src: local('Roboto-Italic'), local('Roboto Italic'), url('roboto/Italic.woff2') format('woff2'), url('roboto/Italic.woff') format('woff');
}

@font-face {
  font-style: italic;
  font-family: 'Roboto';
  font-weight: 700;
  src: local('Roboto-BoldItalic'), local('Roboto Bold Italic'), url('roboto/BoldItalic.woff2') format('woff2'), url('roboto/BoldItalic.woff') format('woff');
}


<|start_filename|>source/prepare.js<|end_filename|>
const { document } = window

const prepareHTMLString = async (convertedFragList) => convertedFragList.join('')
  .replace(/<br>/g, '<br/>') // FIX: wrong tag
  .replace(/&nbsp;/g, ' ') // FIX: svg don't support these markup
  .replace(REGEXP_GLOBAL_MULTILINE_XML_INVALID_CHAR, '')

const prepareCSSString = async (convertedFragList) => `<style>${UA_CSS_PATCH}${convertedFragList.join('')}</style>`
  .replace(/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g, '') // Remove CSS comments. CHECK: https://stackoverflow.com/questions/9329552/explain-regex-that-finds-css-comments
  .replace(/#iefix&/g, '') // FIX: svg don't support this tag
  .replace(REGEXP_GLOBAL_MULTILINE_XML_INVALID_CHAR, '')

// remove XML invalid Char value
const REGEXP_GLOBAL_MULTILINE_XML_INVALID_CHAR = /[^\x09\x0A\x0D\x20-\xFF\x85\xA0-\uD7FF\uE000-\uFDCF\uFDE0-\uFFFD]/gm // eslint-disable-line no-control-regex
const UA_CSS_PATCH = `
input[type="radio"] { -webkit-appearance: radio; -moz-appearance: radio; }
input[type="checkbox"] { -webkit-appearance: checkbox; -moz-appearance: checkbox; }
input[type="radio"], input[type="checkbox"] { box-sizing: border-box; max-width: 20px; max-height: 20px; }
input { -webkit-appearance: textfield; -moz-appearance: textfield; }
select { -webkit-appearance: menulist; -moz-appearance: menulist; }
`

const prepareSVGString = ({ domString, width, height }) => `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="${width}" height="${height}">
<switch>
  <foreignObject width="${width}" height="${height}">
    <body xmlns="http://www.w3.org/1999/xhtml" style="font-size:16px">${domString}</body>
  </foreignObject>
  <text font-size="24">
    <tspan x="${width / 2}" y="${height / 2}" text-anchor="middle">Feature not supported</tspan>
  </text>
</switch>
</svg>`

const prepareImageElement = ({ svgDataUrl, width, height }) => new Promise((resolve, reject) => {
  const imageElement = document.createElement('img')
  imageElement.addEventListener('error', reject)
  imageElement.addEventListener('load', () => resolve(imageElement))
  imageElement.crossOrigin = 'anonymous'
  imageElement.width = width
  imageElement.height = height
  imageElement.src = svgDataUrl
})

const prepareCanvasElement = ({ imageElement, width, height }) => new Promise((resolve) => {
  const canvasElement = document.createElement('canvas')
  canvasElement.width = width
  canvasElement.height = height
  setTimeout(() => { // TODO: HACK: if direct return, Chrome in Linux will crash (but not Firefox)
    canvasElement.getContext('2d').drawImage(imageElement, 0, 0)
    resolve(canvasElement)
  }, 0)
})

export {
  prepareHTMLString,
  prepareCSSString,
  prepareSVGString,
  prepareImageElement,
  prepareCanvasElement
}


<|start_filename|>source/snapshot.js<|end_filename|>
import { fetchTextWithCache } from './fetch'
import { convertFragListWithUrlMap } from './convert'
import { createHTMLFragCollector, createCSSFragCollector } from './fragCollector'
import {
  prepareHTMLString,
  prepareCSSString,
  prepareSVGString,
  prepareImageElement,
  prepareCanvasElement
} from './prepare'

const { document } = window

const createSnapshotFromElement = async ({ element, width, height, skipHeavyRender = false }) => {
  if (!element) throw new Error(`invalid element: ${element}`)

  width = parseInt(width || (element.style.width.endsWith('px') && element.style.width) || element.offsetWidth)
  height = parseInt(height || (element.style.height.endsWith('px') && element.style.height) || element.offsetHeight)
  if (!width || !height) throw new Error(`can not get output size: width: ${width}, height: ${height}`)

  const htmlSourceList = new window.XMLSerializer().serializeToString(element).split('\n')
  __DEV__ && console.log('[createSnapshotFromElement]', { element, htmlSourceList, width, height })

  return createSnapshotFromHTMLSourceList({ htmlSourceList, width, height, skipHeavyRender })
}

const createSnapshotFromHTMLSourceList = async ({ htmlSourceList, width, height, skipHeavyRender = false }) => {
  if (!Array.isArray(htmlSourceList)) throw new Error(`invalid htmlSourceList: ${htmlSourceList}`)
  if (!width || !height) throw new Error(`invalid size: width: ${width}, height: ${height}`)

  const htmlFragCollector = createHTMLFragCollector()
  htmlSourceList.forEach((fragString) => htmlFragCollector.collect(fragString, ''))

  const cssFragCollector = createCSSFragCollector()

  const cssUrlList = Array.from(document.getElementsByTagName('link'))
    .map((element) => (element.rel === 'stylesheet' && element.href))
    .filter((href) => href)
  for (const cssUrl of cssUrlList) {
    cssFragCollector.collect(await fetchTextWithCache(cssUrl), cssUrl) // with originUrl
  }
  Array.from(document.getElementsByTagName('style'))
    .map((element) => cssFragCollector.collect(getStyleElementCSSText(element), ''))

  const htmlFragOutput = htmlFragCollector.getOutput()
  const cssFragOutput = cssFragCollector.getOutput()

  { // reduce unused font files
    const fontFamilySet = new Set([
      ...htmlFragOutput.fontInfo.fontFamilySet,
      ...cssFragOutput.fontInfo.fontFamilySet
    ])

    Object.entries({
      ...htmlFragOutput.fontInfo.fontFaceMap,
      ...cssFragOutput.fontInfo.fontFaceMap
    }).forEach(([ fontTag, urlInfoSet ]) => {
      __DEV__ && !fontFamilySet.has(fontTag.split('|')[ 0 ]) && console.log('[DROP] font tag:', fontTag, urlInfoSet)
      if (!fontFamilySet.has(fontTag.split('|')[ 0 ])) return urlInfoSet.forEach((urlInfo) => { urlInfo.isIgnore = true })

      // reduce font files
      // TODO: may break compatibility (do we have many?) // NOTE: prefer keep by: woff2, woff, or just the first one (ttf/otf maybe)
      let urlInfoWoff2 = null
      let urlInfoWoff = null
      urlInfoSet.forEach((urlInfo) => {
        urlInfoWoff2 = urlInfoWoff2 || (urlInfo.urlString.includes('woff2') ? urlInfo : null)
        urlInfoWoff = urlInfoWoff || (urlInfo.urlString.includes('woff') ? urlInfo : null)
      })

      // drop others
      let hasPickedFirst = false
      urlInfoSet.forEach((urlInfo) => {
        if (urlInfoWoff2 && urlInfo !== urlInfoWoff2) urlInfo.isIgnore = true
        else if (urlInfoWoff && urlInfo !== urlInfoWoff) urlInfo.isIgnore = true
        else {
          urlInfo.isIgnore = hasPickedFirst
          hasPickedFirst = true
        }
        __DEV__ && urlInfo.isIgnore && console.log('[DROP] font src:', urlInfo)
      })
    })
  }

  __DEV__ && console.warn('[createSnapshotFromHTMLSourceList]', { htmlFragOutput, cssFragOutput })

  let htmlString, cssString, domString, svgString, svgDataUrl, imageElement, canvasElement, pngDataUrl
  const packResult = () => ({ htmlString, cssString, domString, svgString, svgDataUrl, imageElement, canvasElement, pngDataUrl })
  try {
    htmlString = await prepareHTMLString(await convertFragListWithUrlMap(htmlFragOutput))
    cssString = await prepareCSSString(await convertFragListWithUrlMap(cssFragOutput))
    domString = `${cssString}\n${htmlString}`
    svgString = await prepareSVGString({ domString, width, height })

    // TODO: HACK: instead of a blobUrl, if we use a dataUrl, chrome seems happy...
    // ISSUE: https://bugs.chromium.org/p/chromium/issues/detail?id=294129
    // FIX DEMO: https://jsfiddle.net/2Lh24rg9/
    // TODO: CHECK: dataUrl Length Limit
    // https://stackoverflow.com/questions/695151/data-protocol-url-size-limitations
    // const blobUrl = URL.createObjectURL(new Blob([ svgString ], { type: 'image/svg+xml' }))
    svgDataUrl = `data:image/svg+xml;charset=utf8,${encodeURIComponent(svgString)}`

    if (!skipHeavyRender) {
      const result = await renderPngDataUrlFromSvgDataUrl({ svgDataUrl, width, height })
      imageElement = result.imageElement
      canvasElement = result.canvasElement
      pngDataUrl = result.pngDataUrl
    }
  } catch (error) { __DEV__ && console.warn('[createSnapshotFromHTMLSourceList] error:', error, packResult()) }
  return packResult()
}

const getStyleElementCSSText = (element) => {
  const { innerHTML } = element
  if (innerHTML.length !== 0) return innerHTML
  // in Chrome, styled-components will generate `empty` style tag and add style to `.sheet.cssRules`
  // use CSS Object Model (CSSOM) API
  const cssTextList = []
  const { sheet: { cssRules } } = element
  for (let index = 0, indexMax = cssRules.length; index < indexMax; index++) {
    const { cssText } = cssRules[ index ]
    cssTextList.push(cssText)
  }
  return cssTextList.join('\n')
}

const renderPngDataUrlFromSvgDataUrl = async ({ svgDataUrl, width, height }) => {
  const imageElement = await prepareImageElement({ svgDataUrl, width, height })
  const canvasElement = await prepareCanvasElement({ imageElement, width, height })
  const pngDataUrl = canvasElement.toDataURL()
  return { imageElement, canvasElement, pngDataUrl }
}

export {
  createSnapshotFromElement,
  createSnapshotFromHTMLSourceList,
  renderPngDataUrlFromSvgDataUrl
}


<|start_filename|>source/fragCollector.js<|end_filename|>
const createFragCollector = ({ extractorList, trimComment }) => {
  let fragList, urlMap, fontInfo

  const getOutput = () => {
    const output = { fragList, urlMap, fontInfo }
    fragList = [] // mostly be fragment string, some will be object for urlInfo: { urlString: '', originUrl: '', dataUrl: '', isIgnore: false } (from urlMap)
    urlMap = {} // [urlString]: { urlString: '', originUrl: '', dataUrl: 'will have data after fetch', isIgnore: false } // urlInfo: url to load
    fontInfo = {
      // fontTag = name|style|weight
      fontFaceMap: {}, // [fontTag]: Set[ urlInfo, urlInfo ]
      fontFamilySet: new Set() // name
    }
    return output
  }

  const collect = (fragString, originUrl = '', skipFontInfo = false) => { // initial fragString should be from file or innerHTML, relatively complete
    // __DEV__ && console.log('[collect] fragString:', fragString)
    if (fragString && trimComment) fragString = trimComment(fragString)
    if (!fragString) return

    const getUrlInfo = (urlString) => {
      if (urlMap[ urlString ] === undefined) urlMap[ urlString ] = { urlString, originUrl, dataUrl: '' }
      return urlMap[ urlString ]
    }
    const collectFragList = extractorList.reduce(
      (collectFragList, extractor) => collectFragList.reduce(
        (fragList, frag) => extractor(fragList, frag, getUrlInfo), []
      ),
      [ fragString ]
    )
    fragList = fragList.concat(collectFragList)

    if (!skipFontInfo && REGEXP_FONT_FAST.test(fragString)) { // get font info
      const tryGetFontFace = (fontFaceString) => {
        const [ name ] = tryGetFontFamily(fontFaceString)
        const urlInfoList = []
        name && extractCSSFontSrc([], fontFaceString, (urlString) => urlInfoList.push(getUrlInfo(urlString)))
        if (!urlInfoList.length) return
        const fontTag = `${name}|${tryGetFontStyle(fontFaceString)}|${tryGetFontWeight(fontFaceString)}`
        // __DEV__ && console.log('[tryGetFontFace]', fontFaceString, fontTag, urlInfoList)
        if (fontInfo.fontFaceMap[ fontTag ] === undefined) fontInfo.fontFaceMap[ fontTag ] = new Set(urlInfoList)
        else addArrayToSet(fontInfo.fontFaceMap[ fontTag ], urlInfoList)
      }
      let index = 0
      let result
      while ((result = REGEXP_GLOBAL_FONT_FACE.exec(fragString))) {
        const [ fontFaceString ] = result
        tryGetFontFace(fontFaceString)
        addArrayToSet(fontInfo.fontFamilySet, tryGetFontFamily(fragString.slice(index, result.index)))
        index = REGEXP_GLOBAL_FONT_FACE.lastIndex
      }
      addArrayToSet(fontInfo.fontFamilySet, tryGetFontFamily(fragString.slice(index)))
    }
  }

  getOutput() // init by reset

  return { getOutput, collect }
}

const addArrayToSet = (set, array) => array.forEach(set.add, set)

// extractor format:
// (fragList, frag, getUrlInfo) => fragList

const createUrlExtractor = (regexpSearchBlock, regexpExtractUrl) => {
  if (!regexpSearchBlock.global) throw new Error(`[createUrlExtractor] global expected for regexpSearchBlock: ${regexpSearchBlock}`)
  if (regexpExtractUrl.global) throw new Error(`[createUrlExtractor] non-global expected for regexpExtractUrl: ${regexpExtractUrl}`)
  return (fragList, frag, getUrlInfo) => {
    if (typeof (frag) === 'object') {
      __DEV__ && console.log(' - [extractor] get object frag', frag)
      fragList.push(frag)
      return fragList // skip urlInfo
    }
    let currentIndex = 0
    let searchResult
    while ((searchResult = regexpSearchBlock.exec(frag)) !== null) { // faster, search potential block
      const blockString = searchResult[ 0 ]
      __DEV__ && console.log(' - - [extractor] get blockString:', blockString)
      const extractResult = regexpExtractUrl.exec(blockString) // slower & preciser, pick url
      const urlString = extractResult && extractResult[ 1 ]
      __DEV__ && !urlString && console.warn(' - - [extractor] false blockString', { blockString, regexpExtractUrl })
      if (!urlString) continue
      __DEV__ && console.log(' - - [extractor] get urlString:', urlString)
      const urlIndex = searchResult.index + extractResult.index + extractResult[ 0 ].indexOf(urlString)
      const preUrlFrag = frag.slice(currentIndex, urlIndex)
      fragList.push(preUrlFrag)
      fragList.push(getUrlInfo(urlString))
      currentIndex = urlIndex + urlString.length
    }
    fragList.push(frag.substr(currentIndex))
    return fragList
  }
}

const REGEXP_GLOBAL_HTML_COMMENT = /<!--([\s\S]*?)-->/g // not considering edge cases
const REGEXP_GLOBAL_CSS_COMMENT = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g // not consider pattern in quotes, check: https://regexr.com/3ifpv

const REGEXP_GLOBAL_IMAGE_TAG = /<img [^>]*>/g
const REGEXP_IMAGE_TAG_SRC = /src=['"]([^'"]*)['"]/
const extractHTMLImageTagSrc = createUrlExtractor(REGEXP_GLOBAL_IMAGE_TAG, REGEXP_IMAGE_TAG_SRC) // extract <img src="URL">

const REGEXP_GLOBAL_INLINE_STYLE = /style=['"].*url\([^)]*\).*['"]/g
const REGEXP_INLINE_STYLE_URL = /url\(['"]?([^)'"]*)['"]?\)/
const extractHTMLStyleInlineUrl = createUrlExtractor(REGEXP_GLOBAL_INLINE_STYLE, REGEXP_INLINE_STYLE_URL) // extract style="background: url(URL);"

const REGEXP_GLOBAL_FONT_FACE = /@font-face\s+{[^}]+font-family[^}]+}/g // https://regexr.com/3ugm3

const REGEXP_FONT_FAST = /font(-family)?\s*:/
const REGEXP_GLOBAL_FONT_FAMILY = /font(-family)?\s*:\s*([^;}>]+)\s*[;}>]?/g // https://regexr.com/3ugnd
const tryGetFontFamily = (string) => {
  const nameList = []
  let result
  while ((result = REGEXP_GLOBAL_FONT_FAMILY.exec(string))) {
    const [ , isFontFamily, nameListString ] = result
    // font-family: all name, like: `font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Zen Hei", sans-serif;`
    // font: not all name, like: `font: italic 1.2em "Fira Sans", serif;`
    nameListString && nameListString.split(',').forEach((string, index) => {
      if (index === 0 && !isFontFamily) { // extract first font family name
        string = string.trim()
        string = string.split(/["']$/.test(string) ? /["']/ : ' ').filter(Boolean).pop()
      }
      const name = trimCSSName(string)
      name && nameList.push(name)
    })
  }
  return nameList
}

const REGEXP_FONT_STYLE = /font-style\s*:\s*(\w+)/
const tryGetFontStyle = (string) => (REGEXP_FONT_STYLE.exec(string) || [])[ 1 ] || 'normal'

const REGEXP_FONT_WEIGHT = /font-weight\s*:\s*(\w+)/ // https://regexr.com/3uigg
const tryGetFontWeight = (string) => {
  let fontWeight = (REGEXP_FONT_WEIGHT.exec(string) || [])[ 1 ]
  if (fontWeight === 'normal') fontWeight = '400'
  else if (fontWeight === 'bold') fontWeight = '700'
  return fontWeight || '400'
}

const REGEXP_DROP_QUOTES = /^["'](.*)["']$/
const trimCSSName = (string = '') => string.trim().replace(REGEXP_DROP_QUOTES, '$1')

// collect HTML fragment & separate url
const createHTMLFragCollector = () => createFragCollector({
  extractorList: [ extractHTMLImageTagSrc, extractHTMLStyleInlineUrl ],
  trimComment: (string) => string.replace(REGEXP_GLOBAL_HTML_COMMENT, '')
})

const REGEXP_GLOBAL_CSS_URL = /url\([^)]*\)/g
const extractCSSImageSrc = createUrlExtractor(REGEXP_GLOBAL_CSS_URL, REGEXP_INLINE_STYLE_URL) // extract style="background: url(URL);"

const REGEXP_CSS_FONT_URL = /(\/?(\.?\.\/)*\b[-\w@:%_+.~#?&/=]+.(woff2|woff|ttf|svg))([?#][-\w%._#=]{1,256})?/ // match woff2/woff/ttf/svg font check https://regexr.com/3ifpv
const extractCSSFontSrc = createUrlExtractor(REGEXP_GLOBAL_CSS_URL, REGEXP_CSS_FONT_URL) // extract src: url(Material-Icons.woff2) format('woff2');

// collect HTML fragment & separate url
const createCSSFragCollector = () => createFragCollector({
  extractorList: [ extractCSSImageSrc, extractCSSFontSrc ],
  trimComment: (string) => string.replace(REGEXP_GLOBAL_CSS_COMMENT, '')
})

export {
  createFragCollector,
  createUrlExtractor,
  createHTMLFragCollector,
  createCSSFragCollector
}


<|start_filename|>source/fetch.js<|end_filename|>
const { fetch, FileReader, URL } = window

const dataUrlToBlobUrl = async (dataUrl) => {
  const response = await fetch(dataUrl)
  const blob = await response.blob()
  return URL.createObjectURL(blob)
}

const blobToDataUrl = (blob) => new Promise((resolve) => {
  const reader = new FileReader()
  reader.addEventListener('load', () => resolve(reader.result), false)
  reader.readAsDataURL(blob)
})

// TODO: use create function to prevent global config data
let FETCH_OPTION = { method: 'GET', cache: 'default', mode: 'cors', credentials: 'same-origin' }
const setFetchOption = (option = {}) => { FETCH_OPTION = { ...FETCH_OPTION, ...option } }

let CACHED_TEXT_FETCH_MAP = {} // cache fetch promise to prevent multi request for the same source
let CACHED_BLOB_FETCH_MAP = {} // cache fetch promise to prevent multi request for the same source
const resetFetchCache = () => {
  CACHED_TEXT_FETCH_MAP = {}
  CACHED_BLOB_FETCH_MAP = {}
}

const fetchTextWithCache = (url) => {
  if (!CACHED_TEXT_FETCH_MAP[ url ]) {
    __DEV__ && console.log('fetchTextWithCache', url)
    CACHED_TEXT_FETCH_MAP[ url ] = fetch(url, FETCH_OPTION).then((response) => response.text())
  }
  return CACHED_TEXT_FETCH_MAP[ url ]
}

const fetchBlobWithCache = (url) => {
  if (!CACHED_BLOB_FETCH_MAP[ url ]) {
    __DEV__ && console.log('fetchBlobWithCache', url)
    CACHED_BLOB_FETCH_MAP[ url ] = fetch(url, FETCH_OPTION).then((response) => response.blob())
  }
  return CACHED_BLOB_FETCH_MAP[ url ]
}

const fetchDataUrlWithCache = async (dataUrl) => blobToDataUrl(await fetchBlobWithCache(dataUrl))

export {
  dataUrlToBlobUrl,
  blobToDataUrl,
  setFetchOption,
  resetFetchCache,
  fetchTextWithCache,
  fetchBlobWithCache,
  fetchDataUrlWithCache
}


<|start_filename|>example/resource/font/test-redundant-fonts.css<|end_filename|>
@font-face {
  font-style: normal;
  font-family: 'FZLanTingHei';
  font-weight: 400;
  src: local('FZLanTingHeiS-R-GB'), url('fangzheng/ht/FZLTHJW.woff2') format('woff2'), url('fangzheng/ht/FZLTHJW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZLanTingHei';
  font-weight: 300;
  src: local('FZLanTingHeiS-L-GB'), url('fangzheng/ht/FZLTXIHJW.woff2') format('woff2'), url('fangzheng/ht/FZLTXIHJW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZLanTingHei';
  font-weight: 700;
  src: local('FZLanTingHeiS-B-GB'), url('fangzheng/ht/FZLTCHJW.woff2') format('woff2'), url('fangzheng/ht/FZLTCHJW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZYuan';
  font-weight: 400;
  src: local('FZZhunYuan-M02S'), url('fangzheng/yt/FZY3JW.woff2') format('woff2'), url('fangzheng/yt/FZY3JW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZYuan';
  font-weight: 300;
  src: local('FZXiYuan-M01S'), url('fangzheng/yt/FZY1JW.woff2') format('woff2'), url('fangzheng/yt/FZY1JW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZYuan';
  font-weight: 700;
  src: local('FZCuYuan-M03S'), url('fangzheng/yt/FZY4JW.woff2') format('woff2'), url('fangzheng/yt/FZY4JW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZXinSong';
  font-weight: 400;
  src: local('FZNewShuSong-Z10S'), url('fangzheng/FZXSSJW.woff2') format('woff2'), url('fangzheng/FZXSSJW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZKai';
  font-weight: 400;
  src: local('FZKai-Z03S'), url('fangzheng/FZKTJW.woff2') format('woff2'), url('fangzheng/FZKTJW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZGongYeHei';
  font-weight: 400;
  src: local('FZGongYHS-R-GB'), url('fangzheng/FZGongYHJW.woff2') format('woff2'), url('fangzheng/FZGongYHJW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZHanZhenGuangBiao';
  font-weight: 400;
  src: local('FZHanZhenGuangBiaoS-GB'), url('fangzheng/FZHZGBJW.woff2') format('woff2'), url('fangzheng/FZHZGBJW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'FZZongYi';
  font-weight: 400;
  src: local('FZZongYi-M05S'), url('fangzheng/FZZYJW.woff2') format('woff2'), url('fangzheng/FZZYJW.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'PingFangSC';
  font-weight: 400;
  src: local('PingFangSC-Regular'), local('PingFang SC Regular'), local('.PingFangSC-Regular'), local('.PingFang SC Regular'), local('PingFangSC'), local('PingFang SC'), url('pingfang/SC-Regular.woff2') format('woff2'), url('pingfang/SC-Regular.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'PingFangSC';
  font-weight: 700;
  src: local('PingFangSC-Semibold'), local('PingFang SC Semibold'), local('.PingFangSC-Semibold'), local('.PingFang SC Semibold'), url('pingfang/SC-Semibold.woff2') format('woff2'), url('pingfang/SC-Semibold.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'SourceHanSansSC';
  font-weight: 400;
  src: local('SourceHanSansSC-Regular'), local('Source Han Sans SC Regular'), local('Source Han Sans SC'), local('Noto Sans CJK SC Regular'), local('Noto Sans CJK SC'), url('source-han/SansCN-Regular.woff2') format('woff2'), url('source-han/SansCN-Regular.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'SourceHanSansSC';
  font-weight: 700;
  src: local('SourceHanSansSC-Bold'), local('Source Han Sans SC Bold'), local('Noto Sans CJK SC Bold'), url('source-han/SansCN-Bold.woff2') format('woff2'), url('source-han/SansCN-Bold.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'SFUIText';
  font-weight: 400;
  src: local('SFUIText-Regular'), local('.SFUIText-Regular'), local('SFProText-Regular'), local('SFProText'), url('sf-ui/Text-Regular.woff2') format('woff2'), url('sf-ui/Text-Regular.woff') format('woff');
}

@font-face {
  font-style: normal;
  font-family: 'SFUIText';
  font-weight: 700;
  src: local('SFUIText-Bold'), local('.SFUIText-Bold'), local('SFProText-Bold'), url('sf-ui/Text-Bold.woff2') format('woff2'), url('sf-ui/Text-Bold.woff') format('woff');
}


<|start_filename|>script/webpack.example.js<|end_filename|>
import { resolve } from 'path'
import { writeFileSync, unlinkSync } from 'fs'

import { compileWithWebpack, commonFlag } from '@dr-js/dev/module/webpack'
import { runMain } from '@dr-js/dev/module/main'

const PATH_ROOT = resolve(__dirname, '..')
const fromRoot = (...args) => resolve(PATH_ROOT, ...args)

const PATH_EXAMPLE = resolve(PATH_ROOT, './example')

const INDEX_FILE = 'source/index.example.js'
const INDEX_FILE_DATA = `
import * as Time from '@dr-js/core/module/common/time'
import * as Format from '@dr-js/core/module/common/format'
import * as Resource from '@dr-js/core/module/browser/resource'

import * as Fetch from './fetch'
import * as Convert from './convert'
import * as FragCollector from './fragCollector'
import * as Prepare from './prepare'

export {
  createSnapshotFromElement,
  createSnapshotFromHTMLSourceList,
  renderPngDataUrlFromSvgDataUrl
} from './snapshot'

export {
  Fetch, Convert, FragCollector, Prepare,
  Format, Time, Resource
}
`

runMain(async (logger) => {
  const { mode, isWatch, profileOutput, getCommonWebpackConfig } = await commonFlag({ fromRoot, logger })

  logger.log(`generate ${INDEX_FILE}`)
  writeFileSync(fromRoot(INDEX_FILE), INDEX_FILE_DATA)
  process.on('exit', () => { // TODO: not working in watch mode (win32)
    logger.log(`delete ${INDEX_FILE}`)
    try { unlinkSync(fromRoot(INDEX_FILE)) } catch (error) { __DEV__ && console.log(error) }
  })

  const config = getCommonWebpackConfig(({
    output: { path: PATH_EXAMPLE, filename: '[name].js', library: 'DomSnapshot', libraryTarget: 'umd' },
    entry: { index: INDEX_FILE }
  }))

  logger.padLog(`compile with webpack mode: ${mode}, isWatch: ${Boolean(isWatch)}`)
  await compileWithWebpack({ config, isWatch, profileOutput, logger })
}, 'webpack-example')
