@typed/content-hash
Version:
Content hash a directory of HTML/JS/CSS files and other static assets
297 lines (246 loc) • 8.55 kB
text/typescript
import { Do } from '@typed/fp/FxEnv'
import { isSome, none, some } from 'fp-ts/Option'
import { getMonoid } from 'fp-ts/ReadonlyArray'
import { foldMap, Tree } from 'fp-ts/Tree'
import { posix } from 'path'
import { red, yellow } from 'typed-colors'
import { debug } from '../../application/services/logging'
import { Dependency, Document, Position } from '../../domain/model'
import { ensureRelative } from '../ensureRelative'
import { fsReadFile } from '../fsReadFile'
import { HashPlugin } from '../HashPlugin'
import { MAIN_FIELDS } from './defaults'
import { getFileExtension } from './getFileExtension'
import { isExternalUrl } from './isExternalUrl'
import { parseSrcSets } from './parseSrcSets'
import { resolvePackage } from './resolvePackage'
export type HtmlAst = {
readonly type: string
readonly tagName: string
readonly attributes: readonly HtmlAttribute[]
readonly children?: readonly HtmlAst[]
readonly content?: string
readonly position: {
readonly start: {
readonly index: number
readonly line: number
readonly column: number
}
readonly end: {
readonly index: number
readonly line: number
readonly column: number
}
}
}
export type HtmlAttribute = {
readonly key: string
readonly value: string
}
export type HtmlParseOptions = { readonly includePositions: true }
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parse, parseDefaults } = require('himalaya') as {
parse: (html: string, options: HtmlParseOptions) => readonly HtmlAst[]
parseDefaults: HtmlParseOptions
}
const supportedFileExtension = ['.html']
const foldDependencies = foldMap(getMonoid<Dependency>())
const searchMap: Readonly<Record<string, readonly string[]>> = {
a: ['href', 'ping'],
applet: ['archive', 'code', 'codebase', 'object', 'src'],
area: ['href', 'ping'],
audio: ['src'],
base: ['href'],
blockquote: ['cite'],
body: ['background'],
button: ['formaction'],
del: ['cite'],
embed: ['src'],
form: ['action'],
frame: ['longdesc', 'src'],
head: ['profile'],
html: ['manifest'],
iframe: ['longdesc', 'src'],
img: ['longdesc', 'src', 'srcset'],
input: ['formaction', 'src'],
ins: ['cite'],
link: ['href'],
menuitem: ['icon'],
object: ['codebase', 'data'],
q: ['cite'],
script: ['src'],
source: ['src', 'srcset'],
table: ['background'],
tbody: ['background'],
td: ['background'],
tfoot: ['background'],
th: ['background'],
thead: ['background'],
tr: ['background'],
track: ['src'],
video: ['poster', 'src'],
} as const
export interface HtmlPuginOptions {
readonly buildDirectory: string
readonly mainFields?: readonly string[]
}
export function createHtmlPlugin({ buildDirectory, mainFields = MAIN_FIELDS }: HtmlPuginOptions): HashPlugin {
const html: HashPlugin = {
readFilePath: (filePath) =>
Do(function* (_) {
const ext = getFileExtension(filePath)
if (!supportedFileExtension.includes(ext)) {
yield* _(debug(`${red(`[HTML]`)} Unsupported file extension ${filePath}`))
return none
}
yield* _(debug(`${yellow(`[HTML]`)} Reading ${filePath}...`))
const initial = yield* _(fsReadFile(filePath, { supportsSourceMaps: false, isBase64Encoded: false }))
yield* _(debug(`${yellow(`[HTML]`)} Finding Dependencies ${filePath}...`))
const document: Document = findDependencies(initial, buildDirectory, mainFields)
return some({ ...document, contentHash: none, sourceMap: none })
}),
}
return html
}
function findDependencies(document: Document, buildDirectory: string, mainFields: readonly string[]) {
const directory = posix.dirname(document.filePath)
const ast = parse(document.contents, { ...parseDefaults, includePositions: true })
const dependencies = ast
.map(astToTree)
.flatMap(foldDependencies(isValidDependency(buildDirectory, directory, mainFields, document.contents)))
return { ...document, dependencies }
}
function astToTree(ast: HtmlAst): Tree<HtmlAst> {
return {
value: ast,
forest: (ast.children || []).map(astToTree),
}
}
function isValidDependency(buildDirectory: string, directory: string, mainFields: readonly string[], contents: string) {
return (ast: HtmlAst): readonly Dependency[] => {
if (ast.type !== 'element') {
return []
}
const tagName = ast.tagName.toLowerCase()
if (tagName === 'template' && !!ast.children?.[0].content) {
const child = ast.children[0]
const start = child.position.start.index
const content = child.content!
const childAst = parse(content, { ...parseDefaults, includePositions: true })
return childAst
.map(astToTree)
.flatMap(foldDependencies(isValidDependency(buildDirectory, directory, mainFields, content)))
.map((d) => ({ ...d, position: { start: d.position.start + start, end: d.position.end + start } }))
}
if (!(tagName in searchMap)) {
return []
}
const attributesToSearch = searchMap[tagName]
return ast.attributes
.filter(({ key }) => attributesToSearch.includes(key))
.flatMap(getDependencies(buildDirectory, directory, mainFields, contents, ast))
}
}
function getDependencies(
buildDirectory: string,
directory: string,
mainFields: readonly string[],
contents: string,
ast: HtmlAst,
) {
const { position } = ast
const astStart = position.start.index
const astEnd = position.end.index
const sourceString = contents.slice(astStart, astEnd)
const tagName = ast.tagName.toLowerCase()
return (attr: HtmlAttribute): ReadonlyArray<Dependency> => {
const attrStart = astStart + findSourceIndex(sourceString, attr)
const attrEnd = attrStart + attr.value.length
const isImgSrcSet = tagName === 'img' && attr.key === 'srcset'
const resolved = isImgSrcSet
? parseSrcSets(attr.value, attrStart).map((s) =>
resolveSpecifier(buildDirectory, directory, s.url, mainFields, s.position),
)
: [
resolveSpecifier(buildDirectory, directory, attr.value, mainFields, {
start: attrStart,
end: attrEnd,
}),
]
return resolved.filter(isSome).map((o) => o.value)
}
}
function findSourceIndex(source: string, attr: HtmlAttribute) {
const woQuotesIndex = source.indexOf(formatNoQuotes(attr))
if (woQuotesIndex > -1) {
return woQuotesIndex + attr.key.length + 1 // + the equals
}
const singleQuotesIndex = source.indexOf(formatSingleQuotes(attr))
if (singleQuotesIndex > -1) {
return singleQuotesIndex + attr.key.length + 2 // + the equals and first quote
}
const doubleQuotesIndex = source.indexOf(formatDoubleQuotes(attr))
if (doubleQuotesIndex > -1) {
return doubleQuotesIndex + attr.key.length + 2
}
throw new Error(`Unable to find HTML attribute ${formatDoubleQuotes(attr)} in ${source}`)
}
function formatNoQuotes(attr: HtmlAttribute): string {
return `${attr.key}=${attr.value}`
}
function formatSingleQuotes(attr: HtmlAttribute): string {
return `${attr.key}='${attr.value}'`
}
function formatDoubleQuotes(attr: HtmlAttribute): string {
return `${attr.key}="${attr.value}"`
}
function ensureRelativeSpecifier(specifier: string, buildDirectory: string, directory: string) {
if (specifier.startsWith('/')) {
return ensureRelative(posix.relative(directory, posix.resolve(buildDirectory, specifier.slice(1))))
}
return specifier
}
function resolveSpecifier(
buildDirectory: string,
directory: string,
specifier: string,
mainFields: readonly string[],
position: Position,
) {
const relativeSpecifier = ensureRelativeSpecifier(specifier, buildDirectory, directory)
const hasFileExtension = isFileExtension(posix.extname(relativeSpecifier))
if (isExternalUrl(relativeSpecifier)) {
return none
}
try {
const filePath = resolvePackage({
moduleSpecifier: relativeSpecifier,
directory,
extensions: ['.js'],
mainFields,
})
const dep: Dependency = {
specifier,
filePath,
fileExtension: getFileExtension(filePath),
position,
}
return some(dep)
} catch (error) {
// If we're really sure it is supposed to be a file, throw the error
if (hasFileExtension) {
throw error
}
return none
}
}
function isFileExtension(ext: string): boolean {
if (!ext.trim()) {
return false
}
const n = Number.parseFloat(ext)
if (!Number.isNaN(n)) {
return false
}
return !ext.includes(' ')
}