Compare commits
No commits in common. "gh-pages" and "master" have entirely different histories.
4
404.html
4
404.html
|
@ -1,4 +0,0 @@
|
||||||
<noscript>
|
|
||||||
<meta http-equiv="Refresh" content="0;url=https://etherdream.com/web2img/">
|
|
||||||
</noscript>
|
|
||||||
<script src=/web2img/x.js></script>
|
|
90
README.md
Normal file
90
README.md
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
# Web2Img
|
||||||
|
|
||||||
|
Web2Img is a tool to bundle your web files into a single image, and extract them via Service Worker at runtime.
|
||||||
|
|
||||||
|
You can use image hosting sites as free CDNs to save bandwidth costs.
|
||||||
|
|
||||||
|
![intro.png](assets/img/intro.png)
|
||||||
|
|
||||||
|
## Tool
|
||||||
|
|
||||||
|
Try It Online: https://etherdream.github.io/web2img/
|
||||||
|
|
||||||
|
OR: https://etherdream.com/web2img/ (latest)
|
||||||
|
|
||||||
|
https://user-images.githubusercontent.com/1072787/132183995-b342c3a8-1408-4819-923e-2d25fde419be.mp4
|
||||||
|
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
Demo: [https://fanhtml5.github.io](https://fanhtml5.github.io)
|
||||||
|
|
||||||
|
Target Files: https://github.com/fanhtml5/fanhtml5.github.io (only 2 files)
|
||||||
|
|
||||||
|
Source Files: https://github.com/fanhtml5/test-site
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
Q: What if JavaScript is disabled?
|
||||||
|
|
||||||
|
A: Unfortunately, the page can't be displayed. You can add a fallback in `404.html` such as:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<noscript>
|
||||||
|
<meta http-equiv=Refresh content="0;url=FALLBACK_URL">
|
||||||
|
</noscript>
|
||||||
|
<script src=/x.js></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Q: What if the browser doesn't support Service Worker?
|
||||||
|
|
||||||
|
A: Same as above. The program will read the contents of `<noscript>` and render them.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Q: Is free CDN safe?
|
||||||
|
|
||||||
|
A: Yes, the program will verify the data integrity.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Q: Is free CDN stable?
|
||||||
|
|
||||||
|
A: Not sure, but you can provide multiple URLs to improve stability.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Q: Can any free CDN be used?
|
||||||
|
|
||||||
|
A: No, CDN must enable CORS, allow empty referrer and "null" origin (or real value).
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Q: Would it be better to optimize the image before uploading?
|
||||||
|
|
||||||
|
A: If the server will re-encode the image, it makes no difference.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Q: Why use `404.html`?
|
||||||
|
|
||||||
|
A: It's an easy way to intercept any path.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Q: How to update files?
|
||||||
|
|
||||||
|
A: Just overwrite `x.js`, the client polls this file every 2 minutes.
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
Q: Will new features be added?
|
||||||
|
|
||||||
|
A: This project is just an experiment, there is a new project named [freecdn](https://github.com/EtherDream/freecdn) which is much more powerful. (better docs will be released soon)
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
62
assets/css/main.css
Normal file
62
assets/css/main.css
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
body, input, textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#txtIgnoredFile.bad {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#divDropZone {
|
||||||
|
border: 1px dashed #aaa;
|
||||||
|
padding: 1em 0;
|
||||||
|
font-size: 2em;
|
||||||
|
text-align: center;
|
||||||
|
color: #aaa;
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#txtConf {
|
||||||
|
height: 15em;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
#txtConf.fold {
|
||||||
|
height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#txtConfWarn, #txtUrlsWarn, #txtCodeWarn {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
#imgPreview {
|
||||||
|
max-width: 400px;
|
||||||
|
min-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
min-height: 100px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
margin: .5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#txtUrls {
|
||||||
|
height: 5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#txtHtml {
|
||||||
|
height: 1.2em;
|
||||||
|
}
|
||||||
|
#txtJs {
|
||||||
|
height: 6em;
|
||||||
|
}
|
||||||
|
#txtJs.fold {
|
||||||
|
height: 1.2em;
|
||||||
|
}
|
BIN
assets/img/intro.png
Normal file
BIN
assets/img/intro.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
521
assets/js/main.js
Normal file
521
assets/js/main.js
Normal file
|
@ -0,0 +1,521 @@
|
||||||
|
function bytesToImgData(bytes) {
|
||||||
|
const nPixel = Math.ceil(bytes.length / 3)
|
||||||
|
const side = Math.ceil(Math.sqrt(nPixel))
|
||||||
|
const u32 = new Uint32Array(side * side)
|
||||||
|
|
||||||
|
// out of bounds => 0
|
||||||
|
for (let i = 0, j = 0; i < nPixel; i++, j += 3) {
|
||||||
|
u32[i] = // 0xAABBGGRR
|
||||||
|
bytes[j + 0] << 0 | // R
|
||||||
|
bytes[j + 1] << 8 | // G
|
||||||
|
bytes[j + 2] << 16 | // B
|
||||||
|
0xff000000 // A (255)
|
||||||
|
}
|
||||||
|
u32.fill(0xff000000, nPixel)
|
||||||
|
|
||||||
|
const u8 = new Uint8ClampedArray(u32.buffer)
|
||||||
|
return new ImageData(u8, side, side)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let hashExp
|
||||||
|
|
||||||
|
function canvasToBlob(canvas) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
canvas.toBlob(blob => {
|
||||||
|
resolve(blob)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas
|
||||||
|
|
||||||
|
async function pack() {
|
||||||
|
const bytes = bundle()
|
||||||
|
const imgData = bytesToImgData(bytes)
|
||||||
|
console.log(imgData.width + '*' + imgData.height, 'bytes:', bytes.length)
|
||||||
|
|
||||||
|
hashExp = await sha256(imgData.data)
|
||||||
|
|
||||||
|
canvas = document.createElement('canvas')
|
||||||
|
canvas.width = imgData.width
|
||||||
|
canvas.height = imgData.height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.putImageData(imgData, 0, 0)
|
||||||
|
|
||||||
|
const blob = await canvasToBlob(canvas)
|
||||||
|
imgPreview.src = URL.createObjectURL(blob)
|
||||||
|
divPreview.hidden = false
|
||||||
|
|
||||||
|
const {width, height} = canvas
|
||||||
|
const size = blob.size.toLocaleString()
|
||||||
|
imgPreview.title = `${width}*${height} [${size} Bytes]`
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewWin
|
||||||
|
|
||||||
|
self.onmessage = function(e) {
|
||||||
|
if (e.source === previewWin && e.data === 'GET_PREVIEW_DATA') {
|
||||||
|
const imgDataUrl = canvas.toDataURL()
|
||||||
|
previewWin.postMessage(imgDataUrl, '*')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewFile() {
|
||||||
|
if (confMap['index.html']) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const files = Object.keys(confMap)
|
||||||
|
const html = files.find(v => v.endsWith('.html'))
|
||||||
|
if (html) {
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
return files[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
btnPreview.onclick = function() {
|
||||||
|
const rand = (Math.random() * 0xffffffff >>> 0).toString(36)
|
||||||
|
const site = `https://web2img-preview-${rand}.etherdream.com/`
|
||||||
|
|
||||||
|
previewWin = open(site + getPreviewFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function verifyConf(str) {
|
||||||
|
const conf = parseJson(str)
|
||||||
|
if (!conf || typeof conf !== 'object') {
|
||||||
|
return 'invalid conf'
|
||||||
|
}
|
||||||
|
const pairs = Object.entries(conf)
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
return 'empty conf'
|
||||||
|
}
|
||||||
|
for (const [path, headers] of pairs) {
|
||||||
|
const data = dataMap[path]
|
||||||
|
if (!data) {
|
||||||
|
return `file not found: ${path}`
|
||||||
|
}
|
||||||
|
if (typeof headers !== 'object') {
|
||||||
|
return `invalid header type: ${path}`
|
||||||
|
}
|
||||||
|
if (headers['content-length'] != data.length) {
|
||||||
|
headers['content-length'] = data.length
|
||||||
|
console.warn('fix content-length:', path)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var res = new Response('', {headers})
|
||||||
|
} catch (err) {
|
||||||
|
return `invalid headers: ${path}`
|
||||||
|
}
|
||||||
|
for (const k in headers) {
|
||||||
|
if (!res.headers.has(k)) {
|
||||||
|
return `unsupported header: ${k} (${path})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conf[path] = Object.fromEntries(res.headers)
|
||||||
|
}
|
||||||
|
confMap = conf
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastConf
|
||||||
|
|
||||||
|
txtConf.onchange = async function() {
|
||||||
|
if (lastConf === this.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastConf = this.value
|
||||||
|
|
||||||
|
const err = verifyConf(lastConf)
|
||||||
|
showConfWarn(err)
|
||||||
|
if (err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await pack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundle() {
|
||||||
|
const confStr = JSON.stringify(confMap) + '\r'
|
||||||
|
const confBin = strToBytes(confStr)
|
||||||
|
const bufs = [confBin]
|
||||||
|
|
||||||
|
for (const path of Object.keys(confMap)) {
|
||||||
|
const data = dataMap[path]
|
||||||
|
bufs.push(data)
|
||||||
|
}
|
||||||
|
return concatBufs(bufs)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// utils
|
||||||
|
//
|
||||||
|
function strToBytes(str) {
|
||||||
|
return new TextEncoder().encode(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToB64(bytes) {
|
||||||
|
return btoa(String.fromCharCode.apply(null, bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(str) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(str)
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUrl(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url)
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRegExp(str) {
|
||||||
|
try {
|
||||||
|
return RegExp(str)
|
||||||
|
} catch (err) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatBufs(bufs) {
|
||||||
|
let size = 0
|
||||||
|
for (const buf of bufs) {
|
||||||
|
size += buf.length
|
||||||
|
}
|
||||||
|
const ret = new Uint8Array(size)
|
||||||
|
let pos = 0
|
||||||
|
for (const v of bufs) {
|
||||||
|
ret.set(v, pos)
|
||||||
|
pos += v.length
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(bytes) {
|
||||||
|
const buf = await crypto.subtle.digest('SHA-256', bytes)
|
||||||
|
return new Uint8Array(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArrayEqual(b1, b2) {
|
||||||
|
if (b1.length !== b2.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (let i = 0; i < b1.length; i++) {
|
||||||
|
if (b1[i] !== b2[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfUI() {
|
||||||
|
const confStr = JSON.stringify(confMap, null, '\t')
|
||||||
|
if (confStr === '{}') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
txtConf.value = confStr
|
||||||
|
txtConf.classList.remove('fold')
|
||||||
|
txtConf.readOnly = false
|
||||||
|
txtUrls.value = ''
|
||||||
|
txtJs.value = ''
|
||||||
|
txtJs.classList.add('fold')
|
||||||
|
optPrivacy.disabled = true
|
||||||
|
chkMinify.disabled = true
|
||||||
|
pack()
|
||||||
|
}
|
||||||
|
|
||||||
|
function showConfWarn(msg) {
|
||||||
|
txtConfWarn.textContent = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUrlsWarn(msg) {
|
||||||
|
txtUrlsWarn.textContent = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCodeWarn(msg) {
|
||||||
|
txtCodeWarn.textContent = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyImage(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image()
|
||||||
|
|
||||||
|
img.onload = async () => {
|
||||||
|
if (!hashExp) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.width
|
||||||
|
canvas.height = img.height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
|
||||||
|
const imgData = ctx.getImageData(0, 0, img.width, img.height)
|
||||||
|
const hashGot = await sha256(imgData.data)
|
||||||
|
|
||||||
|
if (isArrayEqual(hashGot, hashExp)) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
reject('hash incorrect')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
reject('failed to load')
|
||||||
|
}
|
||||||
|
img.crossOrigin = true
|
||||||
|
img.referrerPolicy = 'no-referrer'
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyUrls(urls) {
|
||||||
|
for (const url of urls) {
|
||||||
|
const urlObj = parseUrl(url)
|
||||||
|
if (!urlObj || !/^https?:/.test(urlObj.protocol)) {
|
||||||
|
return `invalid url: ${url}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await verifyImage(url)
|
||||||
|
} catch(err) {
|
||||||
|
return `${err}: ${url}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmplCode
|
||||||
|
let imgUrls
|
||||||
|
|
||||||
|
async function genCode() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('assets/js/tmpl.js')
|
||||||
|
tmplCode = await res.text()
|
||||||
|
} catch (err) {
|
||||||
|
showCodeWarn('failed to load code')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const privacy = optPrivacy.options[optPrivacy.selectedIndex].value
|
||||||
|
const hashStr = hashExp ? bytesToB64(hashExp) : ''
|
||||||
|
const urlsStr = imgUrls.join("', '")
|
||||||
|
let js = `\
|
||||||
|
var HASH = '${hashStr}'
|
||||||
|
var URLS = ['${urlsStr}']
|
||||||
|
var PRIVACY = ${privacy}
|
||||||
|
var UPDATE_INTERVAL = 120
|
||||||
|
var IMG_TIMEOUT = 10
|
||||||
|
|
||||||
|
${tmplCode}
|
||||||
|
`
|
||||||
|
if (chkMinify.checked) {
|
||||||
|
try {
|
||||||
|
const ret = await Terser.minify(js, {
|
||||||
|
enclose: true,
|
||||||
|
compress: {
|
||||||
|
global_defs: {
|
||||||
|
RELEASE: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ie8: true,
|
||||||
|
})
|
||||||
|
js = ret.code
|
||||||
|
} catch (err) {
|
||||||
|
showCodeWarn(err.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txtJs.value = js
|
||||||
|
}
|
||||||
|
|
||||||
|
txtUrls.onchange = async function() {
|
||||||
|
const urls = this.value.split(/\s+/).filter(Boolean)
|
||||||
|
if (urls.length === 0) {
|
||||||
|
txtJs.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
txtJs.value = 'generating...'
|
||||||
|
|
||||||
|
const err = await verifyUrls(urls)
|
||||||
|
|
||||||
|
showUrlsWarn(err)
|
||||||
|
if (err) {
|
||||||
|
txtJs.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imgUrls = urls
|
||||||
|
|
||||||
|
txtJs.classList.remove('fold')
|
||||||
|
optPrivacy.disabled = false
|
||||||
|
chkMinify.disabled = false
|
||||||
|
await genCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
optPrivacy.onchange = function() {
|
||||||
|
genCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
chkMinify.onchange = function() {
|
||||||
|
genCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const MAX_BUNDLE_SIZE = 1024 * 1024 * 20
|
||||||
|
let dataMap = {}
|
||||||
|
let confMap = {}
|
||||||
|
let totalSize = 0
|
||||||
|
|
||||||
|
async function addFile(file, path) {
|
||||||
|
if (checkIgnore(path)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (totalSize + file.size > MAX_BUNDLE_SIZE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
totalSize += file.size
|
||||||
|
|
||||||
|
const data = await readFileData(file)
|
||||||
|
dataMap[path] = data
|
||||||
|
confMap[path] = {
|
||||||
|
'content-type': file.type || 'application/octet-stream',
|
||||||
|
'content-length': file.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isReading = false
|
||||||
|
let ignoreReg
|
||||||
|
|
||||||
|
function checkIgnore(path) {
|
||||||
|
for (const s of path.split('/')) {
|
||||||
|
if (ignoreReg.test(s)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
txtIgnore.onchange = function() {
|
||||||
|
ignoreReg = parseRegExp(this.value || '^$')
|
||||||
|
if (!ignoreReg) {
|
||||||
|
this.classList.add('bad')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.classList.remove('bad')
|
||||||
|
}
|
||||||
|
txtIgnore.onchange()
|
||||||
|
|
||||||
|
|
||||||
|
function startReading() {
|
||||||
|
isReading = true
|
||||||
|
totalSize = 0
|
||||||
|
dataMap = {}
|
||||||
|
confMap = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileDialogHandler() {
|
||||||
|
if (isReading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.files.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startReading()
|
||||||
|
|
||||||
|
for (const file of this.files) {
|
||||||
|
const path = stripRootDir(file.webkitRelativePath)
|
||||||
|
await addFile(file, path)
|
||||||
|
}
|
||||||
|
showConfUI()
|
||||||
|
isReading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
divDropZone.onclick = function() {
|
||||||
|
const fileDialog = document.createElement('input')
|
||||||
|
fileDialog.type = 'file'
|
||||||
|
fileDialog.webkitdirectory = true
|
||||||
|
fileDialog.onchange = fileDialogHandler
|
||||||
|
fileDialog.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
divDropZone.ondragover = function(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
}
|
||||||
|
|
||||||
|
divDropZone.ondrop = async function(e) {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (isReading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const {items} = e.dataTransfer
|
||||||
|
if (items.length !== 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const entry = items[0].webkitGetAsEntry()
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startReading()
|
||||||
|
|
||||||
|
await traverseDir(entry)
|
||||||
|
showConfUI()
|
||||||
|
isReading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function traverseDir(entry) {
|
||||||
|
const entires = await getDirEntries(entry)
|
||||||
|
|
||||||
|
for (const entry of entires) {
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
await traverseDir(entry)
|
||||||
|
} else {
|
||||||
|
const file = await entryToFile(entry)
|
||||||
|
const path = stripRootDir(entry.fullPath)
|
||||||
|
await addFile(file, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripRootDir(path) {
|
||||||
|
return path.replace(/^\/?[^/]+\//, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirEntries(entry) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
entry.createReader().readEntries(entries => {
|
||||||
|
resolve(entries)
|
||||||
|
}, err => {
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryToFile(entry) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
entry.file(ret => {
|
||||||
|
resolve(ret)
|
||||||
|
}, err => {
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileData(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
const u8 = new Uint8Array(reader.result)
|
||||||
|
resolve(u8)
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject()
|
||||||
|
}
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
})
|
||||||
|
}
|
305
assets/js/tmpl.js
Normal file
305
assets/js/tmpl.js
Normal file
|
@ -0,0 +1,305 @@
|
||||||
|
function pageEnv() {
|
||||||
|
var container = document.documentElement
|
||||||
|
|
||||||
|
function fallback(html) {
|
||||||
|
var noscripts = document.getElementsByTagName('noscript')
|
||||||
|
if (noscripts.length > 0) {
|
||||||
|
html = noscripts[0].innerHTML
|
||||||
|
}
|
||||||
|
container.innerHTML = html
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsUrl = document.currentScript.src
|
||||||
|
var sw = navigator.serviceWorker
|
||||||
|
if (!sw) {
|
||||||
|
fallback('Service Worker is not supported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var rootPath = getRootPath(jsUrl)
|
||||||
|
|
||||||
|
|
||||||
|
function unpackToCache(bytes, cache) {
|
||||||
|
var pendings = []
|
||||||
|
|
||||||
|
if (!sw.controller) {
|
||||||
|
var swPending = sw.register(jsUrl).catch(function(err) {
|
||||||
|
fallback(err.message)
|
||||||
|
})
|
||||||
|
pendings.push(swPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = JSON.stringify({
|
||||||
|
hash: HASH,
|
||||||
|
time: Date.now()
|
||||||
|
})
|
||||||
|
var res = new Response(info)
|
||||||
|
pendings.push(
|
||||||
|
cache.put(rootPath + '.cache-info', res),
|
||||||
|
)
|
||||||
|
|
||||||
|
var pathResMap = unpack(bytes)
|
||||||
|
|
||||||
|
for (var path in pathResMap) {
|
||||||
|
res = pathResMap[path]
|
||||||
|
pendings.push(
|
||||||
|
cache.put(rootPath + path, res)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Promise.all(pendings).then(function() {
|
||||||
|
location.reload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseImgBuf(buf) {
|
||||||
|
if (!buf) {
|
||||||
|
loadNextUrl()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crypto.subtle.digest('SHA-256', buf).then(function(digest) {
|
||||||
|
var hashBin = new Uint8Array(digest)
|
||||||
|
var hashB64 = btoa(String.fromCharCode.apply(null, hashBin))
|
||||||
|
if (HASH && HASH !== hashB64) {
|
||||||
|
console.warn('[web2img] bad hash. exp:', HASH, 'but got:', hashB64)
|
||||||
|
loadNextUrl()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var bytes = decode1Px3Bytes(buf)
|
||||||
|
caches.delete('.web2img').then(function() {
|
||||||
|
caches.open('.web2img').then(function(cache) {
|
||||||
|
unpackToCache(bytes, cache)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// run in iframe
|
||||||
|
var loadImg = function(e) {
|
||||||
|
var opt = e.data
|
||||||
|
var img = new Image()
|
||||||
|
|
||||||
|
img.onload = function() {
|
||||||
|
clearInterval(tid)
|
||||||
|
|
||||||
|
var canvas = document.createElement('canvas')
|
||||||
|
canvas.width = img.width
|
||||||
|
canvas.height = img.height
|
||||||
|
|
||||||
|
var ctx = canvas.getContext('2d')
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
|
||||||
|
var imgData = ctx.getImageData(0, 0, img.width, img.height)
|
||||||
|
var buf = imgData.data.buffer
|
||||||
|
|
||||||
|
if (opt.privacy === 2) {
|
||||||
|
parent.postMessage(buf, '*', [buf])
|
||||||
|
} else {
|
||||||
|
parseImgBuf(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img.onerror = function() {
|
||||||
|
clearInterval(tid)
|
||||||
|
|
||||||
|
if (opt.privacy === 2) {
|
||||||
|
parent.postMessage('', '*')
|
||||||
|
} else {
|
||||||
|
parseImgBuf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (opt.privacy === 1) {
|
||||||
|
img.referrerPolicy = 'no-referrer'
|
||||||
|
}
|
||||||
|
img.crossOrigin = 1
|
||||||
|
img.src = opt.url
|
||||||
|
|
||||||
|
var tid = setTimeout(function() {
|
||||||
|
console.log('[web2img] timeout:', opt.url)
|
||||||
|
img.onerror()
|
||||||
|
img.onerror = img.onload = null
|
||||||
|
img.src = ''
|
||||||
|
}, opt.timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PRIVACY === 2) {
|
||||||
|
// hide `origin` header
|
||||||
|
var iframe = document.createElement('iframe')
|
||||||
|
|
||||||
|
if (typeof RELEASE !== 'undefined') {
|
||||||
|
iframe.src = 'data:text/html,<script>onmessage=' + loadImg + '</script>'
|
||||||
|
} else {
|
||||||
|
iframe.src = 'data:text/html;base64,' + btoa('<script>onmessage=' + loadImg + '</script>')
|
||||||
|
}
|
||||||
|
iframe.style.display = 'none'
|
||||||
|
iframe.onload = loadNextUrl
|
||||||
|
|
||||||
|
container.appendChild(iframe)
|
||||||
|
var iframeWin = iframe.contentWindow
|
||||||
|
|
||||||
|
self.onmessage = function(e) {
|
||||||
|
if (e.source === iframeWin) {
|
||||||
|
parseImgBuf(e.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadNextUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadNextUrl() {
|
||||||
|
var url = URLS.shift()
|
||||||
|
if (!url) {
|
||||||
|
fallback('failed to load resources')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var opt = {
|
||||||
|
url: url,
|
||||||
|
privacy: PRIVACY,
|
||||||
|
timeout: IMG_TIMEOUT * 1000
|
||||||
|
}
|
||||||
|
if (PRIVACY === 2) {
|
||||||
|
iframeWin.postMessage(opt, '*')
|
||||||
|
} else {
|
||||||
|
loadImg({data: opt})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decode1Px3Bytes(pixelBuf) {
|
||||||
|
var u32 = new Uint32Array(pixelBuf)
|
||||||
|
var out = new Uint8Array(u32.length * 3)
|
||||||
|
var p = 0
|
||||||
|
u32.forEach(function(rgba) {
|
||||||
|
out[p++] = rgba
|
||||||
|
out[p++] = rgba >> 8
|
||||||
|
out[p++] = rgba >> 16
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpack(bytes) {
|
||||||
|
var confEnd = bytes.indexOf(13) // '\r'
|
||||||
|
var confBin = bytes.subarray(0, confEnd)
|
||||||
|
var confStr = new TextDecoder().decode(confBin)
|
||||||
|
var confObj = JSON.parse(confStr)
|
||||||
|
|
||||||
|
var offset = confEnd + 1
|
||||||
|
|
||||||
|
for (var path in confObj) {
|
||||||
|
var headers = confObj[path]
|
||||||
|
var expires = /\.html$/.test(path) ? 5 : UPDATE_INTERVAL
|
||||||
|
headers['cache-control'] = 'max-age=' + expires
|
||||||
|
|
||||||
|
var len = +headers['content-length']
|
||||||
|
var bin = bytes.subarray(offset, offset + len)
|
||||||
|
|
||||||
|
confObj[path] = new Response(bin, {
|
||||||
|
headers: headers
|
||||||
|
})
|
||||||
|
offset += len
|
||||||
|
}
|
||||||
|
return confObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function swEnv() {
|
||||||
|
var jsUrl = location.href.split('?')[0]
|
||||||
|
var rootPath = getRootPath(jsUrl)
|
||||||
|
var isFirst = 1
|
||||||
|
var newJs
|
||||||
|
|
||||||
|
function openFile(path) {
|
||||||
|
return caches.open('.web2img').then(function(cache) {
|
||||||
|
return cache.match(path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUpdate() {
|
||||||
|
openFile(rootPath + '.cache-info').then(function(res) {
|
||||||
|
if (!res) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json().then(function(info) {
|
||||||
|
if (Date.now() - info.time < 1000 * UPDATE_INTERVAL) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var url, opt
|
||||||
|
if ('cache' in Request.prototype) {
|
||||||
|
url = jsUrl
|
||||||
|
opt = {cache: 'no-cache'}
|
||||||
|
} else {
|
||||||
|
url = jsUrl + '?t=' + Date.now()
|
||||||
|
}
|
||||||
|
fetch(url, opt).then(function(res) {
|
||||||
|
res.text().then(function(js) {
|
||||||
|
if (js.indexOf(info.hash) === -1) {
|
||||||
|
newJs = url
|
||||||
|
console.log('[web2img] new version found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setInterval(checkUpdate, 1000 * UPDATE_INTERVAL)
|
||||||
|
|
||||||
|
function respondFile(url) {
|
||||||
|
var path = new URL(url).pathname
|
||||||
|
.replace(/\/{2,}/g, '/')
|
||||||
|
.replace(/\/$/, '/index.html')
|
||||||
|
|
||||||
|
return openFile(path).then(function(r1) {
|
||||||
|
return r1 || openFile(rootPath + '404.html').then(function(r2) {
|
||||||
|
return r2 || new Response('file not found: ' + path, {
|
||||||
|
status: 404
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function respond(req) {
|
||||||
|
return caches.has('.web2img').then(function(existed) {
|
||||||
|
if (!existed) {
|
||||||
|
// fix cache
|
||||||
|
newJs = jsUrl
|
||||||
|
}
|
||||||
|
if (newJs && req.mode === 'navigate') {
|
||||||
|
var res = new Response('<script src=' + newJs + '></script>', {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/html'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
newJs = ''
|
||||||
|
console.log('[web2img] updating')
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
return respondFile(req.url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onfetch = function(e) {
|
||||||
|
if (isFirst) {
|
||||||
|
isFirst = 0
|
||||||
|
checkUpdate()
|
||||||
|
}
|
||||||
|
var req = e.request
|
||||||
|
if (req.url.indexOf(rootPath) === 0 && req.url.indexOf(jsUrl) !== 0) {
|
||||||
|
// url starts with rootPath (exclude x.js)
|
||||||
|
e.respondWith(respond(req))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oninstall = function() {
|
||||||
|
skipWaiting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRootPath(url) {
|
||||||
|
// e.g.
|
||||||
|
// 'https://mysite.com/'
|
||||||
|
// 'https://xx.github.io/path/to/'
|
||||||
|
return url.split('?')[0].replace(/[^/]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.document) {
|
||||||
|
pageEnv()
|
||||||
|
} else {
|
||||||
|
swEnv()
|
||||||
|
}
|
84
index.html
Normal file
84
index.html
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Web2Img</title>
|
||||||
|
<!-- v0.0.5 -->
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<link rel="stylesheet" href="assets/css/main.css">
|
||||||
|
<base target="_blank">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Web2Img</h1>
|
||||||
|
<div>
|
||||||
|
<p>Web2Img is a tool to bundle your web files into a single image, and extract them via Service Worker at runtime.</p>
|
||||||
|
<p>You can use image hosting sites as free CDNs to save bandwidth costs.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<img class="intro" src="assets/img/intro.png">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Example</h2>
|
||||||
|
<div>
|
||||||
|
Demo: <a href="https://fanhtml5.github.io">fanhtml5.github.io</a>
|
||||||
|
[<a href="https://github.com/fanhtml5/fanhtml5.github.io">Build</a>]
|
||||||
|
[<a href="https://github.com/fanhtml5/test-site">Dev</a>]
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Step1</h2>
|
||||||
|
<div id="divDropZone" title="Click | Drag & Drop">Select WebSite Directory</div>
|
||||||
|
<div>
|
||||||
|
Ignore: <input id="txtIgnore" value="^\." placeholder="ignore nothing">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Step2</h2>
|
||||||
|
<div>
|
||||||
|
<textarea id="txtConf" class="fold" readOnly placeholder="Edit headers Or Remove files"></textarea>
|
||||||
|
<div id="txtConfWarn"></div>
|
||||||
|
</div>
|
||||||
|
<div id="divPreview" hidden>
|
||||||
|
<img id="imgPreview">
|
||||||
|
<div>
|
||||||
|
<button id="btnPreview">Local Preview</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Step3</h2>
|
||||||
|
<div>
|
||||||
|
<textarea id="txtUrls" placeholder="Upload Image And Paste URLs"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="txtUrlsWarn"></div>
|
||||||
|
|
||||||
|
<h2>Save</h2>
|
||||||
|
<div>
|
||||||
|
404.html
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<textarea id="txtHtml"><script src=/x.js></script></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
x.js
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<textarea id="txtJs" class="fold"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="txtCodeWarn"></div>
|
||||||
|
<div>
|
||||||
|
Privacy:
|
||||||
|
<select id="optPrivacy" disabled>
|
||||||
|
<option value="0">Preserve All</option>
|
||||||
|
<option value="1">Hide Referrer</option>
|
||||||
|
<option value="2" selected>Hide Referrer & Origin</option>
|
||||||
|
</select>
|
||||||
|
<input id="chkMinify" type="checkbox" checked disabled>
|
||||||
|
<label for="chkMinify">Minify</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>About</h2>
|
||||||
|
<div>
|
||||||
|
GitHub: <a href="https://github.com/EtherDream/web2img">web2img</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="libs/terser/terser.min.js"></script>
|
||||||
|
<script src="assets/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
1
libs/terser/terser.min.js
vendored
Normal file
1
libs/terser/terser.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
x.js
1
x.js
|
@ -1 +0,0 @@
|
||||||
!function(){var e="GNvVP2tNYSZgSihbivdvePQGFnOzvyOzFkAWzUHpVFo=",n=["https://article.biliimg.com/bfs/article/8894f2b87bad2c59ecf1cc4667d9ff53435fb07b.png","https://pica.zhimg.com/80/v2-185cadc0fc4eb38ce8bb28392a190064.png","https://upload-images.jianshu.io/upload_images/6294093-863581a3f201bfe3.png","https://cdn.nlark.com/yuque/0/2021/png/1527627/1631934906658-70583db5-d45a-4ec0-8d53-f1168d77f3fb.png"],t=120;function r(e){return e.split("?")[0].replace(/[^/]+$/,"")}self.document?function(){var a=document.documentElement;function o(e){var n=document.getElementsByTagName("noscript");n.length>0&&(e=n[0].innerHTML),a.innerHTML=e}var i=document.currentScript.src,c=navigator.serviceWorker;if(c){var s=r(i),u=function(e){var n=e.data,t=new Image;t.onload=function(){clearInterval(r);var e=document.createElement("canvas");e.width=t.width,e.height=t.height;var a=e.getContext("2d");a.drawImage(t,0,0);var o=a.getImageData(0,0,t.width,t.height).data.buffer;2===n.privacy?parent.postMessage(o,"*",[o]):p(o)},t.onerror=function(){clearInterval(r),2===n.privacy?parent.postMessage("","*"):p()},1===n.privacy&&(t.referrerPolicy="no-referrer"),t.crossOrigin=1,t.src=n.url;var r=setTimeout((function(){console.log("[web2img] timeout:",n.url),t.onerror(),t.onerror=t.onload=null,t.src=""}),n.timeout)},f=document.createElement("iframe");f.src="data:text/html,<script>onmessage="+u+"<\/script>",f.style.display="none",f.onload=d,a.appendChild(f);var l=f.contentWindow;self.onmessage=function(e){e.source===l&&p(e.data)}}else o("Service Worker is not supported");function h(n,r){var a=[];if(!c.controller){var u=c.register(i)["catch"]((function(e){o(e.message)}));a.push(u)}var f=JSON.stringify({hash:e,time:Date.now()}),l=new Response(f);a.push([r.put(s+".cache-info",l)]);var h=function(e){var n=e.indexOf(13),r=e.subarray(0,n),a=(new TextDecoder).decode(r),o=JSON.parse(a),i=n+1;for(var c in o){var s=o[c],u=/\.html$/.test(c)?5:t;s["cache-control"]="max-age="+u;var f=+s["content-length"],l=e.subarray(i,i+f);o[c]=new Response(l,{headers:s}),i+=f}return o}(n);for(var p in h)l=h[p],a.push(r.put(s+p,l));Promise.all(a).then((function(){location.reload()}))}function p(n){n?crypto.subtle.digest("SHA-256",n).then((function(t){var r=new Uint8Array(t),a=btoa(String.fromCharCode.apply(null,r));if(e!==a)return console.warn("[web2img] bad hash. exp:",e,"but got:",a),void d();var o,i,c,s=(o=new Uint32Array(n),i=new Uint8Array(3*o.length),c=0,o.forEach((function(e){i[c++]=e,i[c++]=e>>8,i[c++]=e>>16})),i);caches["delete"](".web2img").then((function(){caches.open(".web2img").then((function(e){h(s,e)}))}))})):d()}function d(){var e=n.shift();if(e){var t={url:e,privacy:2,timeout:1e4};l.postMessage(t,"*")}else o("failed to load resources")}}():function(){var e,n=location.href.split("?")[0],t=r(n),a=1;function o(e){return caches.open(".web2img").then((function(n){return n.match(e)}))}function i(){o(t+".cache-info").then((function(t){t&&t.json().then((function(t){var r,a;Date.now()-t.time<12e4||("cache"in Request.prototype?(r=n,a={cache:"no-cache"}):r=n+"?t="+Date.now(),fetch(r,a).then((function(n){n.text().then((function(n){-1===n.indexOf(t.hash)&&(e=r,console.log("[web2img] new version found"))}))})))}))}))}function c(r){return caches.has(".web2img").then((function(a){if(a||(e=n),e&&"navigate"===r.mode){var i=new Response("<script src="+e+"><\/script>",{headers:{"content-type":"text/html"}});return e="",console.log("[web2img] updating"),i}return c=r.url,o(s=new URL(c).pathname.replace(/\/{2,}/g,"/").replace(/\/$/,"/index.html")).then((function(e){return e||o(t+"404.html").then((function(e){return e||new Response("file not found: "+s,{status:404})}))}));var c,s}))}setInterval(i,12e4),onfetch=function(e){a&&(a=0,i());var r=e.request;0===r.url.indexOf(t)&&0!==r.url.indexOf(n)&&e.respondWith(c(r))},oninstall=function(){skipWaiting()}}()}();
|
|
Loading…
Reference in a new issue