This commit is contained in:
Claudia Meadows 2024-09-24 14:19:23 -07:00
parent 53a4c2f3b4
commit d32d59413e
No known key found for this signature in database
GPG key ID: C86B594396786760
66 changed files with 30 additions and 11387 deletions

View file

@ -1,20 +0,0 @@
"use strict"
process.on("unhandledRejection", (e) => {
process.exitCode = 1
if (!e.stdout || !e.stderr) throw e
console.error(e.stack)
if (e.stdout?.length) {
console.error(e.stdout.toString("utf-8"))
}
if (e.stderr?.length) {
console.error(e.stderr.toString("utf-8"))
}
// eslint-disable-next-line no-process-exit
process.exit()
})

View file

@ -1,244 +0,0 @@
// Disabling this globally as I use it a lot to speed up common operations and cut down on
// duplicate comparisons.
/* eslint-disable no-bitwise */
"use strict"
const win1252Map = [
0x20AC,
0x81,
0x201A,
0x0192,
0x201E,
0x2026,
0x2020,
0x2021,
0x02C6,
0x2030,
0x0160,
0x2039,
0x0152,
0x8D,
0x017D,
0x8F,
0x90,
0x2018,
0x2019,
0x201C,
0x201D,
0x2022,
0x2013,
0x2014,
0x02DC,
0x2122,
0x0161,
0x203A,
0x0153,
0x9D,
0x017E,
0x0178,
]
function decode(buffer, encoding) {
switch (encoding) {
case "utf16be":
buffer.swap16()
encoding = "utf16le"
break
case "win1252":
encoding = "latin1"
for (let i = 0; i < buffer.length; i++) {
const value = buffer[i]
if ((value & 0xE0) === 0x80) {
const u16 = new Uint16Array(buffer.length)
u16.set(buffer.subarray(0, i), 0)
for (; i < buffer.length; i++) {
const value = buffer[i]
const mask = -((value & 0xE0) === 0x80)
u16[i] = value & ~mask | win1252Map[value & 0x1F] & mask
}
buffer = Buffer.from(u16.buffer)
encoding = "utf16le"
break
}
}
break
}
return buffer.toString(encoding)
}
// Ref: https://encoding.spec.whatwg.org/#concept-encoding-get
/** @type {Array<["utf8" | "utf16le" | "utf16be" | "win1252", string]>} */
const encodingMap = [
["utf8", "UNICODE11UTF8"],
["utf8", "UNICODE20UTF8"],
["utf8", "UNICODE-1-1-UTF-8"],
["utf8", "UTF8"],
["utf8", "UTF-8"],
["utf8", "X-UNICODE20UTF8"],
["win1252", "ANSI_X3.4-1968"],
["win1252", "ASCII"],
["win1252", "CP1252"],
["win1252", "CP819"],
["win1252", "CSISOLATIN1"],
["win1252", "IBM819"],
["win1252", "ISO-8859-1"],
["win1252", "ISO-IR-100"],
["win1252", "ISO8859-1"],
["win1252", "ISO88591"],
["win1252", "ISO_8859-1"],
["win1252", "ISO_8859-1:1987"],
["win1252", "L1"],
["win1252", "LATIN1"],
["win1252", "US-ASCII"],
["win1252", "WINDOWS-1252"],
["win1252", "X-CP1252"],
["utf16be", "UNICODEFFFE"],
["utf16be", "UTF-16BE"],
["utf16le", "CSUNICODE"],
["utf16le", "ISO-10646-UCS-2"],
["utf16le", "UCS-2"],
["utf16le", "UNICODE"],
["utf16le", "UNICODEFEFF"],
["utf16le", "UTF-16"],
["utf16le", "UTF-16LE"],
]
function extractNamedEncoding(name) {
outer:
for (const entry of encodingMap) {
const expected = entry[1]
if (expected.length !== name.length) continue
for (let i = 0; i < name.length; i++) {
let ch = expected.charCodeAt(i)
const upper = ch & ~0x20
if (upper >= 0x41 && upper <= 0x5A) ch = upper
if (name.charCodeAt(i) !== expected) continue outer
}
return entry[0]
}
return undefined
}
function isAsciiWhitespace(ch) {
const mask = (
1 << (0x09 - 1) |
1 << (0x0A - 1) |
1 << (0x0C - 1) |
1 << (0x0D - 1) |
1 << (0x20 - 1)
)
ch |= 0
return ch < 0x20 && (mask >>> (ch - 1) & 1) !== 0
}
function startsWith(buffer, i, end, sequence) {
if (buffer.length < i + sequence.length) return false
for (let j = 0; j < sequence.length && i < end; i++, j++) {
let ch = sequence.charCodeAt(j)
if (ch === 0x20) {
if (!isAsciiWhitespace(buffer[i++])) return false
while (i < buffer.length && isAsciiWhitespace(buffer[i])) i++
} else {
const upper = ch & ~0x20
if (upper >= 0x41 && upper <= 0x5A) ch = upper
if (ch !== buffer[i]) return false
}
}
return true
}
const metasToCheck = encodingMap.flatMap(([e, n]) => [
[e, `charset=${n}>`],
[e, `charset="${n}">`],
[e, `charset='${n}'>`],
[e, `charset=${n}/>`],
[e, `charset="${n}"/>`],
[e, `charset='${n}'/>`],
[e, `http-equiv=content-type content=${n}>`],
[e, `http-equiv="content-type" content=${n}>`],
[e, `http-equiv='content-type' content=${n}>`],
[e, `http-equiv=content-type content="${n}">`],
[e, `http-equiv="content-type" content="${n}">`],
[e, `http-equiv='content-type' content="${n}">`],
[e, `http-equiv=content-type content='${n}'>`],
[e, `http-equiv="content-type" content='${n}'>`],
[e, `http-equiv='content-type' content='${n}'>`],
[e, `http-equiv=content-type content=${n}/>`],
[e, `http-equiv="content-type" content=${n}/>`],
[e, `http-equiv='content-type' content=${n}/>`],
[e, `http-equiv=content-type content="${n}"/>`],
[e, `http-equiv="content-type" content="${n}"/>`],
[e, `http-equiv='content-type' content="${n}"/>`],
[e, `http-equiv=content-type content='${n}'/>`],
[e, `http-equiv="content-type" content='${n}'/>`],
[e, `http-equiv='content-type' content='${n}'/>`],
])
function extractMetaEncoding(buffer, i, end) {
// Exceptionally lazy and not quite fully correct
for (const [encoding, meta] of metasToCheck) {
if (startsWith(buffer, i, end, meta)) return encoding
}
return undefined
}
/**
* @returns {"utf8" | "utf16le" | "utf16be" | "win1252"}
*/
function detectEncoding(headers, prefix) {
// This follows the HTML spec to the extent Node supports the various encodings. I'm *not*,
// however, going to bend over backwards to support obscure encodings.
// https://html.spec.whatwg.org/multipage/parsing.html#prescan-a-byte-stream-to-determine-its-encoding
if (startsWith(prefix, 0, prefix.length, "\xEF\xBB\xBF")) return "utf8"
if (startsWith(prefix, 0, prefix.length, "\xFE\xFF")) return "utf16le"
if (startsWith(prefix, 0, prefix.length, "\xFF\xFE")) return "utf16be"
const contentType = headers["content-type"]
if (contentType) {
const result = (/;\s*charset="?([\w-]+)"?/i).exec(contentType)
if (result) {
const encoding = extractNamedEncoding(result[1])
if (encoding) return encoding
}
}
if (startsWith(prefix, 0, prefix.length, "\x3c\x00\x3F\x00\x78\x00")) return "utf16le"
if (startsWith(prefix, 0, prefix.length, "\x00\x3c\x00\x3F\x00\x78")) return "utf16be"
for (let i = 0, end = prefix.indexOf("<!--", 0, "latin1"); i < prefix.length;) {
if (i === end) {
i = prefix.indexOf("-->", i + 4, "latin1")
if (i < 0) return undefined
i += 3
end = prefix.indexOf("<!--", i, "latin1")
} else if (prefix[i] === 0x3C) {
i++
if (i === prefix.length) return "win1252"
if (startsWith(prefix, i, end, "meta ")) {
const encoding = extractMetaEncoding(prefix, i, end)
if (encoding) return encoding
} else if (prefix[i] === 0x21 || prefix[i] === 0x2F || prefix[i] === 0x3F) {
i = prefix.indexOf(0x3E, i)
if (i < 0) return "win1252"
i++
}
}
}
return "win1252"
}
function decodeResponse(headers, body) {
return decode(body, detectEncoding(headers, body.subarray(0, 1024)))
}
module.exports = {
decodeResponse,
}

View file

@ -1,162 +0,0 @@
"use strict"
const fs = require("fs")
const path = require("path")
const {submitTask} = require("./task-queue.js")
const {processOne} = require("./process-file.js")
const {root, rel, p, warnError} = require("../_utils.js")
const doNotVisit = /[\\/]node_modules(?:$|[\\/])|[\\/]docs[\\/](?:changelog|recent-changes|migration-[^\\/]*)\.md$/
function lintOne(file, callback) {
let warnings = 0
let errors = 0
let files = 0
let pending = 1
function settle() {
if (--pending === 0) {
callback(warnings, errors, files)
}
}
function visitNext(file, contents) {
if (contents !== undefined) {
files++
pending++
processOne(file, contents, (w, e) => {
warnings += w
errors += e
settle()
})
} else {
pending++
submitTask(fs.readdir.bind(null, file), (err, files) => {
if (err) {
if (err.code !== "ENOTDIR") {
warnError(err)
}
} else {
for (const child of files) {
const joined = path.join(file, child)
if (!doNotVisit.test(joined)) {
visit(joined)
}
}
}
settle()
})
}
}
function visit(file) {
if (file.endsWith(".md")) {
pending++
submitTask(fs.readFile.bind(null, file, "utf8"), (err, contents) => {
// Not found is fine. Just ignore it.
if (!err || err.code === "EISDIR") {
visitNext(file, err ? undefined : contents)
} else if (err.code !== "ENOENT") {
warnError(err)
}
settle()
})
} else {
visitNext(file, undefined)
}
}
visit(file)
}
function lintAll() {
lintOne(root, (warnings, errors, files) => {
let problems = ""
if (errors !== 0) {
process.exitCode = 1
problems = `${problems}\n${errors} error${errors === 1 ? "" : "s"}`
}
if (warnings !== 0) {
problems = `${problems}\n${warnings} warning${warnings === 1 ? "" : "s"}`
}
if (problems !== "") {
console.error(`${problems} found in the docs\n`)
console.error(`Scanned ${files} file${files === 1 ? "" : "s"}\n`)
} else {
console.log("The docs are in good shape!\n")
console.log(`Scanned ${files} file${files === 1 ? "" : "s"}\n`)
}
})
}
function lintFile(file, callback) {
if (doNotVisit.test(file)) {
if (typeof callback === "function") process.nextTick(callback)
} else {
lintOne(file, (warnings, errors) => {
const relativePath = rel(file)
let problems = ""
if (errors !== 0) {
problems = `${problems}\n${errors} error${errors === 1 ? "" : "s"}`
}
if (warnings !== 0) {
problems = `${problems}\n${warnings} warning${warnings === 1 ? "" : "s"}`
}
if (problems !== "") {
console.error(`${problems} found in ${relativePath}\n`)
}
callback?.()
})
}
}
function lintWatch() {
const timers = new Map()
const handleFileInner = (filename) => {
timers.delete(filename)
lintFile(p(filename))
}
const handleFile = (filename) => {
const timer = timers.get(filename)
if (timer !== undefined) {
timer.refresh()
} else {
timers.set(filename, setTimeout(handleFileInner, 400, filename))
}
}
let fileBuffer = []
lintFile(root, () => {
for (const file of fileBuffer) {
handleFile(file)
}
fileBuffer = undefined
})
fs.watch(root, {recursive: true}, (_, filename) => {
if (fileBuffer !== undefined) {
fileBuffer.push(filename)
} else {
handleFile(filename)
}
})
}
module.exports = {
lintAll,
lintWatch,
}

View file

@ -1,145 +0,0 @@
"use strict"
// Accept just about anything by using Babel's parser.
const babelParser = require("@babel/parser")
function getJsonError(code) {
try {
JSON.parse(code)
return undefined
} catch (e) {
return e
}
}
/** Returns `undefined` or an error */
function getBabelError(code, asTypeScript) {
// Could be within any production.
/** @type {babelParser.ParserPlugin[]} */
const plugins = [
"bigInt",
"asyncGenerators",
"classPrivateMethods",
"classPrivateProperties",
"classProperties",
"dynamicImport",
"logicalAssignment",
"nullishCoalescingOperator",
"numericSeparator",
"objectRestSpread",
"optionalCatchBinding",
"optionalChaining",
"topLevelAwait",
"jsx",
]
if (asTypeScript) {
plugins.push("typescript")
}
try {
babelParser.parse(code, {
sourceType: "unambiguous",
allowReturnOutsideFunction: true,
allowAwaitOutsideFunction: true,
allowSuperOutsideMethod: true,
allowUndeclaredExports: true,
plugins,
})
return undefined
} catch (e) {
return e
}
}
/**
* @typedef LangEntry
* @property {string} name
* @property {undefined | RegExp} unspacedComment
* @property {(code: string) => undefined | Error} getError
*/
/** @type {Map<string, string | LangEntry>} */
const recognizedLangTags = new Map([
["js", {
name: "JavaScript",
unspacedComment: /(^|\s)\/\/\S/g,
getError: (code) => getBabelError(code, false),
}],
["ts", {
name: "TypeScript",
unspacedComment: /(^|\s)\/\/\S/g,
getError: (code) => getBabelError(code, true),
}],
["json", {
name: "JSON",
unspacedComment: undefined,
getError: getJsonError,
}],
["javascript", "js"],
["typescript", "ts"],
])
/**
* @param {undefined | string} lang
* @returns {undefined | LangEntry}
*/
function lookupLang(lang) {
while (typeof lang === "string") {
lang = recognizedLangTags.get(lang)
}
return lang
}
function lintCodeIsHighlightable(codeErrors, lang) {
// We only care about what's not tagged here.
if (lang === "") {
// TODO: ensure all code blocks have tags, and check this in CI.
const langTags = []
for (const [tag, getError] of recognizedLangTags) {
if (typeof getError === "function" && !getError(tag)) {
langTags.push(tag)
}
}
if (langTags.length === 1) {
codeErrors.push(`Code block possibly missing \`${langTags[0]}\` language tag.`)
} else if (langTags.length !== 0) {
codeErrors.push([
"Code block possibly missing a language tag. Possible tags that could apply:",
...langTags.map((tag) => `- ${tag}`),
].join("\n"))
}
}
}
function lintCodeIsSyntaticallyValid(codeErrors, langEntry, error) {
if (error) {
codeErrors.push(`${langEntry.name} code block has invalid syntax: ${error.message}`)
}
}
function lintCodeCommentStyle(codeErrors, langEntry, code) {
if (langEntry?.unspacedComment?.test(code)) {
codeErrors.push("Comment is missing a preceding space.")
}
}
function getCodeLintErrors(code, lang) {
const langEntry = lookupLang(lang)
const error = langEntry?.getError(code)
const codeErrors = []
lintCodeIsHighlightable(codeErrors, lang)
lintCodeIsSyntaticallyValid(codeErrors, langEntry, error)
lintCodeCommentStyle(codeErrors, langEntry, code)
return codeErrors
}
module.exports = {
getCodeLintErrors,
}

View file

@ -1,105 +0,0 @@
"use strict"
const {tryFetch} = require("./try-fetch.js")
function checkKnownCorrectRequestFail(href, headers, status, body) {
if (status >= 400 && status <= 499) {
return `${href} is a broken link (status: ${status})`
}
// Don't fail if something weird shows up - it's the internet. Just log it and move on.
// However, some more sophisticated logging is useful.
let message = `HTTP error for ${href} (status: ${status})`
for (const [name, value] of Object.entries(headers)) {
if (Array.isArray(value)) {
for (const v of value) message = `${message}\n>${name}: ${v}`
} else {
message = `${message}\n>${name}: ${value}`
}
}
if (body !== "") {
message = `${message}\n>${body}`
}
return message
}
/**
* Returns `undefined` if no error, a string if an error does occur.
* @param {(message?: string) => void} callback
*/
function checkHttpInner(href, callback) {
// Prefer https: > http: where possible, but allow http: when https: is inaccessible.
const url = new URL(href)
url.hash = ""
const isHTTPS = url.protocol === "https:"
url.protocol = "https:"
tryFetch(url, (headers, status, body, sslError) => {
if (status >= 200 && status <= 299) {
if (isHTTPS) {
return callback()
} else {
return callback(`Change ${href} to use \`https:\``)
}
}
if (!sslError) {
return callback(checkKnownCorrectRequestFail(href, headers, status, body))
}
url.protocol = "http:"
tryFetch(url, (headers, status, body) => {
if (status >= 200 && status <= 299) {
if (isHTTPS) {
return callback(`Change ${href} to use \`http:\``)
} else {
return callback()
}
}
return callback(checkKnownCorrectRequestFail(href, headers, status, body))
})
})
}
// Kill the remaining duplication by using a global cache.
const urlCache = new Map()
/**
* Returns `undefined` if no error, a string if an error does occur.
* @param {(message?: string) => void} callback
*/
function checkHttp(href, callback) {
if (href.includes("#")) {
process.exitCode = 1
callback(`Expected href to be sanitized of hashes, but found ${href}`)
}
if (urlCache.has(href)) {
const message = urlCache.get(href)
if (Array.isArray(message)) {
message.push(callback)
} else {
process.nextTick(callback, message)
}
} else {
const queue = []
urlCache.set(href, queue)
checkHttpInner(href, (message) => {
urlCache.set(href, message)
process.nextTick(callback, message)
for (const callback of queue) {
process.nextTick(callback, message)
}
})
}
}
module.exports = {
checkHttp,
}

View file

@ -1,22 +0,0 @@
"use strict"
const fs = require("fs")
const path = require("path")
/** @param {(message?: string) => void} callback */
function checkLocal(base, href, callback) {
const exec = (/^([^#?]*\.md)(?:$|\?|#)/).exec(href)
if (exec !== null) {
fs.access(path.join(base, exec[1]), (err) => {
if (err) {
callback(`Broken internal link: ${href}`)
} else {
callback()
}
})
}
}
module.exports = {
checkLocal,
}

View file

@ -1,230 +0,0 @@
"use strict"
const path = require("path")
const {marked} = require("marked")
const {getCodeLintErrors} = require("./lint-code.js")
const {checkHttp} = require("./lint-http-link.js")
const {checkLocal} = require("./lint-relative-link.js")
const {rel} = require("../_utils.js")
const {submitTask} = require("./task-queue.js")
/** @param {string} contents */
function processOne(file, contents, callback) {
/*
Unfortunately, most of this code is just working around a missing feature that's compounded on
by a lexer bug.
- No location info on lexer tokens: https://github.com/markedjs/marked/issues/2134
- Tabs not preserved in lexer tokens' raw text: https://github.com/markedjs/marked/issues/3440
This took far too long to debug, like several hours of it. But I do have correct offsets now.
*/
const relativePath = rel(file)
const base = path.dirname(file)
const syncErrors = []
let errors = 0
let warnings = 0
let pending = 1
const settle = () => {
if (--pending === 0) {
callback(warnings, errors)
}
}
const getSpanLineCol = (startOffset, endOffset) => {
let source = contents.slice(0, startOffset)
let line = 1
let next = -1
let prev = -1
while ((next = source.indexOf("\n", prev + 1)) >= 0) {
line++
prev = next
}
const startLine = line
const startCol = startOffset - prev
source = contents.slice(0, endOffset)
while ((next = source.indexOf("\n", prev + 1)) >= 0) {
line++
prev = next
}
const endLine = line
const endCol = endOffset - prev
return {startLine, startCol, endLine, endCol}
}
const showMessage = (startOffset, endOffset, label, message) => {
const {startLine, startCol, endLine, endCol} = getSpanLineCol(startOffset, endOffset)
if (!message.endsWith("\n")) message += "\n"
if (process.env.CI === "true") {
console.error(
`::${label.toLowerCase()} file=${relativePath}` +
`,line:${startLine}` +
`,endLine=${endLine}` +
`,col:${startCol}` +
`,endColumn=${endCol}` +
`::${relativePath}:${startLine}:${startCol}: ${message}`
)
} else {
console.error(`${label} in ${relativePath}:${startLine}:${startCol}: ${message}`)
}
}
const asyncWarnCallback = (startOffset, endOffset, message) => {
if (message !== undefined) {
warnings++
showMessage(startOffset, endOffset, "Warning", message)
}
settle()
}
const asyncErrorCallback = (startOffset, endOffset, message) => {
if (message !== undefined) {
errors++
showMessage(startOffset, endOffset, "Error", message)
}
settle()
}
/**
* @param {number} startOffset
* @param {import("marked").Tokens.TableCell[]} cells
*/
const visitCellList = (startOffset, parentOffset, cells, parent) => {
for (const cell of cells) {
parentOffset = visitList(startOffset, parentOffset, cell.tokens, parent)
}
return parentOffset
}
// Nasty workaround for https://github.com/markedjs/marked/issues/3440
const advanceTabViaSpaceReplacement = (offset, raw, start, end) => {
while (start < end) {
const real = contents.charCodeAt(offset++)
const synthetic = raw.charCodeAt(start++)
if (
real === 0x09 && synthetic === 0x20 &&
raw.charCodeAt(start) === 0x20 &&
raw.charCodeAt(++start) === 0x20 &&
raw.charCodeAt(++start) === 0x20
) {
start++
}
}
return offset
}
/**
* @param {number} startOffset
* @param {import("marked").MarkedToken[]} tokens
*/
const visitList = (startOffset, parentOffset, tokens, parent) => {
for (const child of tokens) {
const nextIndex = parent.raw.indexOf(child.raw, parentOffset)
const innerStart = advanceTabViaSpaceReplacement(startOffset, parent.raw, parentOffset, nextIndex)
const outerStart = advanceTabViaSpaceReplacement(innerStart, child.raw, 0, child.raw.length)
parentOffset = nextIndex + child.raw.length
startOffset = outerStart
visit(innerStart, child)
}
return parentOffset
}
const visited = new Set()
/**
* @param {number} startOffset
* @param {import("marked").MarkedToken} token
*/
const visit = (startOffset, token) => {
const endOffset = startOffset + token.raw.length
switch (token.type) {
case "link": {
// Make sure it's trimmed, so I don't have to worry about errors elsewhere.
const href = token.href.replace(/^\s+|\s+$|#[\s\S]*$/, "")
if (!visited.has(href)) {
visited.add(href)
// Prefer https: > http: where possible, but allow http: when https: is
// inaccessible.
if ((/^https?:\/\//).test(href)) {
submitTask(
checkHttp.bind(null, href),
asyncWarnCallback.bind(null, startOffset, endOffset),
)
pending++
} else if (!href.includes(":")) {
submitTask(
checkLocal.bind(null, base, href),
asyncErrorCallback.bind(null, startOffset, endOffset),
)
pending++
}
}
visitList(startOffset, 0, token.tokens, token)
break
}
case "code": {
const code = token.text
const lang = token.lang || ""
const codeErrors = getCodeLintErrors(code, lang)
if (codeErrors.length !== 0) {
errors += codeErrors.length
for (const error of codeErrors) {
syncErrors.push({startOffset, endOffset, message: error})
}
}
break
}
case "list":
visitList(startOffset, 0, token.items, token)
break
case "table": {
let parentOffset = visitCellList(startOffset, 0, token.header, token)
startOffset += parentOffset
for (const row of token.rows) {
parentOffset = visitCellList(startOffset, parentOffset, row, token)
startOffset += parentOffset
}
break
}
default:
if (token.tokens !== undefined) {
visitList(startOffset, 0, token.tokens, token)
}
}
}
visitList(0, 0, marked.lexer(contents), {raw: contents.replace(/\t/g, " ")})
for (const {startOffset, endOffset, message} of syncErrors) {
showMessage(startOffset, endOffset, "Error", message)
}
syncErrors.length = 0
settle()
}
module.exports = {
processOne,
}

View file

@ -1,43 +0,0 @@
"use strict"
// CI needs a much lower limit so it doesn't hang.
const maxConcurrency = process.env.CI === "true" ? 5 : 20
const queue = []
let running = 0
function runTask(task, callback) {
process.nextTick(task, (...args) => {
process.nextTick(callback, ...args)
if (running === maxConcurrency && queue.length !== 0) {
const [nextTask, nextCallback] = queue.splice(0, 2)
runTask(nextTask, nextCallback)
}
})
}
/**
* @template {any[]} A
* @param {(callback: (...args: A) => void) => void} task
* @param {(...args: A) => void} callback
*/
function submitTask(task, callback) {
if (typeof task !== "function") {
throw new TypeError("`task` must be a function")
}
if (typeof callback !== "function") {
throw new TypeError("`callback` must be a function")
}
if (running < maxConcurrency) {
running++
runTask(task, callback)
} else {
queue.push(task, callback)
}
}
module.exports = {
submitTask,
}

View file

@ -1,224 +0,0 @@
"use strict"
const http = require("http")
const https = require("https")
const {decodeResponse} = require("./decode-response.js")
const {warnError, noop} = require("../_utils.js")
/**
* Always returns a response object.
* @param {URL} url
* @param {(
* headers: Record<string, string | string[]>,
* status: number,
* body: string,
* sslError: boolean,
* ) => void} callback
*/
function tryFetch(url, callback) {
const maxResponseBytes = 64 * 1024
const maxTimeoutMs = 5000
const maxDelayMs = 10000
const allowedAttempts = 3
const allowedRedirects = 10
let remainingAttempts = allowedAttempts
let remainingRedirects = allowedRedirects
let lastIsSSL = false
let lastStatus = 0
let lastHeaders, lastMessage
let request, response
const responseBuffer = Buffer.alloc(maxResponseBytes)
let responseBytes = 0
let timer
function cleanup() {
const prevReq = request
const prevRes = response
const prevTimer = timer
request = undefined
response = undefined
timer = undefined
clearTimeout(prevTimer)
try {
prevReq?.off("response", onResponse)
prevReq?.off("error", onError)
prevReq?.on("error", noop)
prevReq?.destroy()
} catch (e) {
warnError(e)
}
try {
prevRes?.off("data", onChunk)
prevRes?.off("end", onEnd)
prevRes?.off("error", onError)
prevRes?.destroy()
} catch (e) {
warnError(e)
}
}
function settle() {
cleanup()
callback(lastHeaders, lastStatus, lastMessage, lastIsSSL)
}
function onEnd() {
cleanup()
if (lastMessage === "") {
lastMessage = decodeResponse(lastHeaders, responseBuffer.subarray(0, responseBytes))
}
if (lastStatus === 429 || lastStatus >= 500) {
const retryAfter = Number(lastHeaders["retry-after"])
if (retryAfter > 0) {
setTimeout(loop, Math.max(maxDelayMs, retryAfter))
} else {
setTimeout(loop, 5000)
}
} else {
settle()
}
}
function onError(e) {
cleanup()
if (lastMessage === "") {
lastMessage = e.message
if (e.code === "ECONNRESET" || e.code === "ECONNABORT" || e.code === "ECONNREFUSED") {
lastMessage = "Request socket dropped"
} else if (
url.protocol === "https:" &&
(e.code === "ERR_TLS_CERT_ALTNAME_INVALID" || (/ssl/i).test(e.message))
) {
lastIsSSL = true
} else if (!("code" in e)) {
lastMessage = e.stack
}
}
loop()
}
function onChunk(chunk) {
const length = chunk.length
if (length === 0) return
let next = responseBytes + length
if (next > maxResponseBytes) {
chunk = chunk.subarray(0, length - (next - maxResponseBytes))
next = maxResponseBytes
}
responseBuffer.set(chunk, responseBytes)
responseBytes = next
if (next === maxResponseBytes) {
response.off("data", onChunk)
response.resume()
}
}
function onResponse(res) {
request.off("response", onResponse)
request.off("error", onError)
response = res
response.on("end", onEnd)
response.on("error", onError)
lastStatus = res.statusCode
lastHeaders = res.headers
if (
lastStatus === 301 ||
lastStatus === 302 ||
lastStatus === 303 ||
lastStatus === 307 ||
lastStatus === 308
) {
if (!lastHeaders.location) {
lastMessage = "Redirect missing location"
response.resume()
return
}
try {
url = new URL(lastHeaders.location, url)
} catch {
lastMessage = `Redirection to invalid URL ${lastHeaders.location}`
response.resume()
return
}
remainingAttempts = allowedAttempts
remainingRedirects--
} else if (lastStatus >= 200 && lastStatus <= 299) {
response.resume()
} else {
response.on("data", onChunk)
}
}
function onTimeout() {
if (lastMessage === "") {
lastMessage = "Request timed out"
}
loop()
}
function loop() {
cleanup()
if (remainingAttempts === 0) {
return settle()
}
lastIsSSL = false
lastStatus = 0
lastMessage = ""
lastHeaders = {}
remainingAttempts--
if (remainingRedirects === 0) {
lastMessage = "Too many redirects"
return settle()
}
timer = setTimeout(onTimeout, maxTimeoutMs)
request = (url.protocol === "https:" ? https : http).get(url, {
// pass along realistic headers, some sites (i.e. the IETF) return a 403 otherwise.
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"DNT": "1",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
},
})
request.on("response", onResponse)
request.on("error", onError)
request.end()
}
loop()
}
module.exports = {
tryFetch,
}

View file

@ -1,35 +0,0 @@
/* eslint-disable no-process-exit */
"use strict"
const {execFileSync} = require("child_process")
const remoteInfo = execFileSync("git", ["remote", "-v"], {
windowsHide: true,
stdio: ["inherit", "pipe", "inherit"],
encoding: "utf-8",
}).trim().split(/\r\n?|\n/g)
function find(type) {
const regexp = new RegExp(
"\t(?:" +
"(?:(?:git+)?https?|git|ssh)://(?:[^@\\s]+@)?github\\.com/|" +
"git@github\\.com:" +
")" +
`MithrilJS/mithril\\.js\\.git \\(${type}\\)$`
)
const line = remoteInfo.find((line) => regexp.test(line))
return line == null ? undefined : {
remote: line.slice(0, line.indexOf("\t")),
repo: line.slice(line.lastIndexOf("\t") + 1, -(type.length + 3)),
}
}
exports.fetch = find("fetch")
exports.push = find("push")
if (exports.fetch == null) {
console.error("You must have an upstream to pull from!")
process.exit(1)
}

View file

@ -1,26 +0,0 @@
"use strict"
const path = require("path")
const root = path.dirname(__dirname)
const p = (...args) => path.resolve(root, ...args)
const rel = (file) => path.relative(root, file).replace(/\\/g, "/")
const noop = () => {}
function warnError(e) {
// Don't care about any of these.
if ((/^(?:ECONNRESET|ECONNABORT|EPIPE)$/).test(e.code)) {
return
}
process.exitCode = 1
console.warn(e.stack)
}
module.exports = {
root,
p,
rel,
warnError,
noop,
}

View file

@ -1,307 +0,0 @@
"use strict"
require("./_improve-rejection-crashing.js")
const {promises: fs} = require("fs")
const path = require("path")
const {promisify} = require("util")
const {marked} = require("marked")
const rimraf = promisify(require("rimraf"))
const {execFileSync} = require("child_process")
const escapeRegExp = require("escape-string-regexp")
const HTMLMinifier = require("html-minifier-terser")
const upstream = require("./_upstream")
const version = require("../package.json").version
const r = (file) => path.resolve(__dirname, "..", file)
const metaDescriptionRegExp = /<!--meta-description\n([\s\S]+?)\n-->/m
// Minify our docs.
const htmlMinifierConfig = {
collapseBooleanAttributes: true,
collapseWhitespace: true,
conservativeCollapse: true,
continueOnParseError: true,
minifyCss: {
compatibility: "ie9",
},
minifyJs: true,
minifyUrls: true,
preserveLineBreaks: true,
removeAttributeQuotes: true,
removeCdatasectionsFromCdata: true,
removeComments: true,
removeCommentsFromCdata: true,
removeEmptyAttributes: true,
removeOptionalTags: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
}
async function generate() {
return (await makeGenerator()).generate()
}
async function makeGenerator() {
await rimraf(r("dist"))
const [guides, methods, layout, pkg] = await Promise.all([
fs.readFile(r("docs/nav-guides.md"), "utf-8"),
fs.readFile(r("docs/nav-methods.md"), "utf-8"),
fs.readFile(r("docs/layout.html"), "utf-8"),
fs.readFile(r("package.json"), "utf-8"),
fs.mkdir(r("dist"), {recursive: true}),
])
const version = JSON.parse(pkg).version
// Make sure we have the latest archive.
execFileSync("git", [
"fetch", "--depth=1",
upstream.fetch.remote, "gh-pages",
])
// Set up archive directories
execFileSync("git", [
"checkout", `${upstream.fetch.remote}/gh-pages`,
"--", "archive",
])
await fs.rename(r("archive"), r("dist/archive"))
await fs.mkdir(r(`dist/archive/v${version}`), {recursive: true})
// Tell Git to ignore our changes - it's no longer there.
execFileSync("git", ["add", "archive"])
// add version selector
const docsSelect = await archiveDocsSelect()
return new Generator({
version,
guides,
methods,
layout: layout.replaceAll("[archive-docs]", docsSelect)
})
}
async function getArchiveDirs() {
const dirs = await fs.readdir(r("dist/archive"))
const ver = "v" + version;
if (dirs.every((dir) => ver !== dir)) dirs.push(ver);
return dirs.reverse();
}
async function archiveDocsSelect() {
const archiveDirs = await getArchiveDirs()
var options = archiveDirs
.map((ad) => `<option>${ad}</option>`)
.join("")
return `<select id="archive-docs" onchange="location.href='/archive/' + this.value + '/index.html'">${options}</select>`
}
function encodeHTML (str) {
const charsToEncode = /[&"'<>]/g
const encodeTo = {
"&": "&amp;",
"\"": "&quot;",
"'": "&#39;",
"<": "&lt;",
">": "&gt;",
}
return str.replace(charsToEncode, function(char) { return encodeTo[char] })
}
function extractMetaDescription(markdown) {
var match = markdown.match(metaDescriptionRegExp)
if (match) {
return encodeHTML(match[1])
}
return "Mithril.js Documentation"
}
class Generator {
constructor(opts) {
this._version = opts.version
this._guides = opts.guides
this._methods = opts.methods
this._layout = opts.layout
}
async compilePage(file, markdown) {
file = path.basename(file)
const link = new RegExp(
`([ \t]*)(- )(\\[.+?\\]\\(${escapeRegExp(file)}\\))`
)
const src = link.test(this._guides) ? this._guides : this._methods
const metaDescription = extractMetaDescription(markdown)
let body = markdown.replace(metaDescriptionRegExp, "")
// fix pipes in code tags
body = body.replace(/`((?:\S| -> |, )+)(\|)(\S+)`/gim,
(match, a, b, c) =>
`<code>${(a + b + c).replace(/\|/g, "&#124;")}</code>`
)
// inject menu
body = body.replace(
/(^# .+?(?:\r?\n){2,}?)(?:(-(?:.|\r|\n)+?)((?:\r?\n){2,})|)/m,
(match, title, nav) => {
if (!nav) {
return title + src.replace(link, "$1$2**$3**") + "\n\n"
}
return title + src.replace(link, (match, space, li, link) =>
`${space}${li}**${link}**\n${
nav.replace(/(^|\n)/g, `$1\t${space}`)
}`
) + "\n\n"
}
)
// fix links
body = body.replace(/(\]\([^\)]+)(\.md)/gim, (match, path, extension) =>
path + ((/http/).test(path) ? extension : ".html")
)
const markedHtml = marked(body)
const title = body.match(/^#\s+([^\n\r]+)/m) || []
let result = this._layout
if (title[1]) {
result = result.replace(
/<title>Mithril\.js<\/title>/,
`<title>${title[1]} - Mithril.js</title>`
)
}
// update version
result = result.replace(/\[version\]/g, this._version)
// insert parsed HTML
result = result.replace(/\[body\]/, markedHtml)
// insert meta description
result = result.replace(/\[metaDescription\]/, metaDescription)
// fix anchors
const anchorIds = new Map()
result = result.replace(
/<h([1-6]) id="([^"]+)">(.+?)<\/h\1>/gim,
(match, n, id, text) => {
let anchor = text.toLowerCase()
.replace(/<(\/?)code>/g, "")
.replace(/<a.*?>.+?<\/a>/g, "")
.replace(/[.`[\]\/()]|&quot;/g, "")
.replace(/\s/g, "-");
const anchorId = anchorIds.get(anchor)
anchorIds.set(anchor, anchorId != null ? anchorId + 1 : 0)
if (anchorId != null) anchor += anchorId
return `<h${n} id="${anchor}">` +
`<a href="#${anchor}">${text}</a>` +
`</h${n}>`
}
)
return result
}
async eachTarget(relative, init) {
await Promise.all([
init(r(`dist/archive/v${this._version}/${relative}`)),
init(r(`dist/${relative}`)),
])
}
async generateSingle(file) {
const relative = path.relative(r("docs"), file)
const archived = (target, init) =>
this.eachTarget(target, async (dest) => {
await fs.mkdir(path.dirname(dest), {recursive: true})
await init(dest)
})
if (!(/\.(md|html)$/).test(file)) {
await archived(relative, (dest) => fs.copyFile(file, dest))
console.log(`Copied: ${relative}`)
}
else {
let html = await fs.readFile(file, "utf-8")
if (file.endsWith(".md")) html = await this.compilePage(file, html)
const minified = await HTMLMinifier.minify(html, htmlMinifierConfig)
await archived(
relative.replace(/\.md$/, ".html"),
(dest) => fs.writeFile(dest, minified)
)
console.log(`Compiled: ${relative}`)
}
}
async generateRec(file) {
let files
try {
files = await fs.readdir(file)
}
catch (e) {
if (e.code !== "ENOTDIR") throw e
return this.generateSingle(file)
}
const devOnly = /^layout\.html$|^archive$|^nav-/
// Don't care about the return value here.
await Promise.all(
files
.filter((f) => !devOnly.test(f))
.map((f) => this.generateRec(path.join(file, f)))
)
}
async generate() {
await this.generateRec(r("docs"))
// Just ensure it exists.
await (await fs.open(r("dist/.nojekyll"), "a")).close()
}
}
function watch() {
let timeout, genPromise
function updateGenerator() {
if (timeout == null) return
clearTimeout(timeout)
genPromise = new Promise((resolve) => {
timeout = setTimeout(function() {
timeout = null
resolve(makeGenerator().then((g) => g.generate()))
}, 100)
})
}
async function updateFile(file) {
if ((/^layout\.html$|^archive$|^nav-/).test(file)) {
updateGenerator()
}
(await genPromise).generateSingle(file)
}
async function removeFile(file) {
(await genPromise).eachTarget(file, (dest) => fs.unlink(dest))
}
// eslint-disable-next-line global-require
require("chokidar").watch(r("docs"), {
ignored: ["archive/**", /(^|\\|\/)\../],
// This depends on `layout`/etc. existing first.
ignoreInitial: true,
awaitWriteFinish: true,
})
.on("ready", updateGenerator)
.on("add", updateFile)
.on("change", updateFile)
.on("unlink", removeFile)
.on("unlinkDir", removeFile)
}
if (process.argv.includes("--watch", 2)) {
watch()
} else {
generate()
}

View file

@ -1,12 +0,0 @@
#!/usr/bin/env node
"use strict"
require("./_improve-rejection-crashing.js")
const {lintAll, lintWatch} = require("./_lint-docs/do-lint.js")
if (process.argv.includes("--watch", 2)) {
lintWatch()
} else {
lintAll()
}

View file

@ -2,12 +2,24 @@
/* eslint-disable no-process-exit */
"use strict"
// This is my temporary hack to simplify deployment until I fix the underlying
// problems in these bugs:
// - https://github.com/MithrilJS/mithril.js/issues/2417
// - https://github.com/MithrilJS/mithril.js/pull/2422
process.on("unhandledRejection", (e) => {
process.exitCode = 1
require("./_improve-rejection-crashing.js")
if (!e.stdout || !e.stderr) throw e
console.error(e.stack)
if (e.stdout?.length) {
console.error(e.stdout.toString("utf-8"))
}
if (e.stderr?.length) {
console.error(e.stderr.toString("utf-8"))
}
// eslint-disable-next-line no-process-exit
process.exit()
})
const {promises: fs} = require("fs")
const path = require("path")

View file

@ -1,42 +0,0 @@
#!/usr/bin/env node
/* eslint-disable no-process-exit */
"use strict"
// This is my temporary hack to simplify deployment until I fix the underlying
// problems in these bugs:
// - https://github.com/MithrilJS/mithril.js/issues/2417
// - https://github.com/MithrilJS/mithril.js/pull/2422
require("./_improve-rejection-crashing.js")
const path = require("path")
const {execFileSync} = require("child_process")
const ghPages = require("gh-pages")
const upstream = require("./_upstream")
const generate = require("./generate-docs")
async function update() {
await generate()
const commit = execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
windowsHide: true,
stdio: "inherit",
encoding: "utf-8",
})
await ghPages.publish(path.resolve(__dirname, "../dist"), {
// Note: once this is running on Travis again, run
// `git remote add upstream git@github.com:MithrilJS/mithril.js.git` to
// force it to go over SSH so the saved keys are used.
// https://github.com/tschaub/gh-pages/issues/160
repo: upstream.push.repo,
remote: upstream.push.remote,
src: ["**/*", ".nojekyll"],
message: `Generated docs for commit ${commit} [skip ci]`,
// May want to enable this if an API token resolves the issue.
// silent: !!process.env.TRAVIS_CI,
})
console.log("Published!")
}
update()