mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-30 10:36:35 +02:00
258 lines
7.2 KiB
TypeScript
258 lines
7.2 KiB
TypeScript
import * as fs from 'fs-extra';
|
|
import { pathExistsSync } from 'fs-extra';
|
|
const Entities = require('html-entities').AllHtmlEntities;
|
|
const htmlparser2 = require('@joplin/fork-htmlparser2');
|
|
const Datauri = require('datauri/sync');
|
|
import { CssTypes, parse as cssParse, stringify as cssStringify } from '@adobe/css-tools';
|
|
|
|
const selfClosingElements = [
|
|
'area',
|
|
'base',
|
|
'basefont',
|
|
'br',
|
|
'col',
|
|
'command',
|
|
'embed',
|
|
'frame',
|
|
'hr',
|
|
'img',
|
|
'input',
|
|
'isindex',
|
|
'keygen',
|
|
'link',
|
|
'meta',
|
|
'param',
|
|
'source',
|
|
'track',
|
|
'wbr',
|
|
];
|
|
|
|
const htmlentities = (s: string): string => {
|
|
const output = (new Entities()).encode(s);
|
|
return output.replace(/	/ig, '\t');
|
|
};
|
|
|
|
const dataUriEncode = (filePath: string): string => {
|
|
try {
|
|
const result = Datauri(filePath);
|
|
return result.content;
|
|
} catch (error) {
|
|
// If the file path is invalid, the Datauri will throw an exception.
|
|
// Instead, since we can just ignore that particular file.
|
|
// Fixes https://github.com/laurent22/joplin/issues/8305
|
|
return '';
|
|
}
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const attributesHtml = (attr: any) => {
|
|
const output = [];
|
|
|
|
for (const n in attr) {
|
|
if (!attr.hasOwnProperty(n)) continue;
|
|
output.push(`${n}="${htmlentities(attr[n])}"`);
|
|
}
|
|
|
|
return output.join(' ');
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const attrValue = (attrs: any, name: string): string => {
|
|
if (!attrs[name]) return '';
|
|
return attrs[name];
|
|
};
|
|
|
|
const isSelfClosingTag = (tagName: string) => {
|
|
return selfClosingElements.includes(tagName.toLowerCase());
|
|
};
|
|
|
|
const processCssContent = (cssBaseDir: string, content: string): string => {
|
|
const o = cssParse(content, {
|
|
silent: false,
|
|
});
|
|
|
|
for (const rule of o.stylesheet.rules) {
|
|
if (rule.type === 'font-face') {
|
|
for (const declaration of rule.declarations) {
|
|
if (declaration.type === CssTypes.comment) {
|
|
continue;
|
|
}
|
|
|
|
if (declaration.property === 'src') {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
declaration.value = declaration.value.replace(/url\((.*?)\)/g, (_v: any, url: string) => {
|
|
const cssFilePath = `${cssBaseDir}/${url}`;
|
|
if (fs.existsSync(cssFilePath)) {
|
|
return `url(${dataUriEncode(cssFilePath)})`;
|
|
} else {
|
|
return `url(${url})`;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return cssStringify(o);
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const processLinkTag = (baseDir: string, _name: string, attrs: any): string => {
|
|
const href = attrValue(attrs, 'href');
|
|
if (!href) return null;
|
|
|
|
const filePath = `${baseDir}/${href}`;
|
|
|
|
if (!pathExistsSync(filePath)) return null;
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
return `<style>${processCssContent(dirname(filePath), content)}</style>`;
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const processScriptTag = (baseDir: string, _name: string, attrs: any): string => {
|
|
const src = attrValue(attrs, 'src');
|
|
if (!src) return null;
|
|
|
|
const scriptFilePath = `${baseDir}/${src}`;
|
|
let content = fs.readFileSync(scriptFilePath, 'utf8');
|
|
|
|
// There's no simple way to insert arbitrary content in <script> tags.
|
|
// Encoding HTML entities doesn't work because the JS parser will not decode
|
|
// them before parsing. We also can't put the code verbatim since it may
|
|
// contain strings such as `</script>` or `<!--` which would break the HTML
|
|
// file.
|
|
//
|
|
// So it seems the only way is to escape these specific sequences with a
|
|
// backslash. It shouldn't break the JS code and should allow the HTML
|
|
// parser to work as expected.
|
|
//
|
|
// https://stackoverflow.com/a/41302266/561309
|
|
|
|
content = content.replace(/<script>/g, '<\\script>');
|
|
content = content.replace(/<\/script>/g, '<\\/script>');
|
|
content = content.replace(/<!--/g, '<\\!--');
|
|
|
|
return `<script>${content}</script>`;
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const processImgTag = (baseDir: string, _name: string, attrs: any): string => {
|
|
const src = attrValue(attrs, 'src');
|
|
if (!src) return null;
|
|
|
|
const filePath = `${baseDir}/${src}`;
|
|
if (!fs.existsSync(filePath)) return null;
|
|
|
|
const modAttrs = { ...attrs };
|
|
delete modAttrs.src;
|
|
return `<img src="${dataUriEncode(filePath)}" ${attributesHtml(modAttrs)}/>`;
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
const processAnchorTag = (baseDir: string, _name: string, attrs: any): string => {
|
|
const href = attrValue(attrs, 'href');
|
|
if (!href) return null;
|
|
|
|
const filePath = `${baseDir}/${href}`;
|
|
if (!fs.existsSync(filePath)) return null;
|
|
|
|
const modAttrs = { ...attrs };
|
|
modAttrs.href = dataUriEncode(filePath);
|
|
modAttrs.download = basename(filePath);
|
|
return `<a ${attributesHtml(modAttrs)}>`;
|
|
};
|
|
|
|
function basename(path: string) {
|
|
if (!path) throw new Error('Path is empty');
|
|
const s = path.split(/\/|\\/);
|
|
return s[s.length - 1];
|
|
}
|
|
|
|
function dirname(path: string) {
|
|
if (!path) throw new Error('Path is empty');
|
|
const s = path.split(/\/|\\/);
|
|
s.pop();
|
|
return s.join('/');
|
|
}
|
|
|
|
export default async function htmlpack(inputFile: string, outputFile: string): Promise<void> {
|
|
const inputHtml = await fs.readFile(inputFile, 'utf8');
|
|
const baseDir = dirname(inputFile);
|
|
|
|
const output: string[] = [];
|
|
|
|
interface Tag {
|
|
name: string;
|
|
}
|
|
|
|
const tagStack: Tag[] = [];
|
|
|
|
const currentTag = () => {
|
|
if (!tagStack.length) return { name: '', processed: false };
|
|
return tagStack[tagStack.length - 1];
|
|
};
|
|
|
|
const parser = new htmlparser2.Parser({
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
|
onopentag: (name: string, attrs: any) => {
|
|
name = name.toLowerCase();
|
|
|
|
let processedResult = '';
|
|
|
|
if (name === 'link') {
|
|
processedResult = processLinkTag(baseDir, name, attrs);
|
|
}
|
|
|
|
if (name === 'script') {
|
|
processedResult = processScriptTag(baseDir, name, attrs);
|
|
}
|
|
|
|
if (name === 'img') {
|
|
processedResult = processImgTag(baseDir, name, attrs);
|
|
}
|
|
|
|
if (name === 'a') {
|
|
processedResult = processAnchorTag(baseDir, name, attrs);
|
|
}
|
|
|
|
tagStack.push({ name });
|
|
|
|
if (processedResult) {
|
|
output.push(processedResult);
|
|
} else {
|
|
let attrHtml = attributesHtml(attrs);
|
|
if (attrHtml) attrHtml = ` ${attrHtml}`;
|
|
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
|
|
output.push(`<${name}${attrHtml}${closingSign}`);
|
|
}
|
|
},
|
|
|
|
ontext: (decodedText: string) => {
|
|
if (currentTag().name === 'style') {
|
|
// For CSS, we have to put the style as-is inside the tag because if we html-entities encode
|
|
// it, it's not going to work. But it's ok because JavaScript won't run within the style tag.
|
|
// Ideally CSS should be loaded from an external file.
|
|
output.push(decodedText);
|
|
} else {
|
|
output.push(htmlentities(decodedText));
|
|
}
|
|
},
|
|
|
|
onclosetag: (name: string) => {
|
|
const current = currentTag();
|
|
|
|
if (current.name === name.toLowerCase()) tagStack.pop();
|
|
|
|
if (isSelfClosingTag(name)) return;
|
|
output.push(`</${name}>`);
|
|
},
|
|
|
|
}, { decodeEntities: true });
|
|
|
|
parser.write(inputHtml);
|
|
parser.end();
|
|
|
|
await fs.writeFile(outputFile, output.join(''), 'utf8');
|
|
}
|