feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
272
packages/htmlpack/packToWriter.ts
普通文件
272
packages/htmlpack/packToWriter.ts
普通文件
@@ -0,0 +1,272 @@
|
||||
import { AllHtmlEntities as Entities } from 'html-entities';
|
||||
import { CssTypes, parse as cssParse, stringify as cssStringify } from '@adobe/css-tools';
|
||||
import { dirname, basename } from 'path';
|
||||
import parseHtmlAsync, { HtmlAttrs } from './utils/parseHtmlAsync';
|
||||
|
||||
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 attributesHtml = (attrs: HtmlAttrs) => {
|
||||
const output: string[] = [];
|
||||
|
||||
for (const n in attrs) {
|
||||
if (!attrs.hasOwnProperty(n)) continue;
|
||||
output.push(`${n}="${htmlentities(attrs[n])}"`);
|
||||
}
|
||||
|
||||
return output.join(' ');
|
||||
};
|
||||
|
||||
const attrValue = (attrs: HtmlAttrs, name: string): string => {
|
||||
if (!attrs[name]) return '';
|
||||
return attrs[name];
|
||||
};
|
||||
|
||||
const isSelfClosingTag = (tagName: string) => {
|
||||
return selfClosingElements.includes(tagName.toLowerCase());
|
||||
};
|
||||
|
||||
export type FileApiChunkCallback = (chunk: string)=> void | Promise<void>;
|
||||
|
||||
export type FileApi = {
|
||||
exists(path: string): Promise<boolean>;
|
||||
readFileText(path: string): Promise<string>;
|
||||
readFileDataUri(path: string): Promise<string>;
|
||||
streamFileDataUri?(path: string, onChunk: FileApiChunkCallback): Promise<void>;
|
||||
writeChunk(chunk: string): void | Promise<void>;
|
||||
};
|
||||
|
||||
// packToWriter should be able to run in React Native -- don't use fs-extra.
|
||||
const packToWriter = async (baseDir: string, inputFileText: string, fs: FileApi) => {
|
||||
const readFileDataUriSafe = async (path: string) => {
|
||||
try {
|
||||
return await fs.readFileDataUri(path);
|
||||
} 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 '';
|
||||
}
|
||||
};
|
||||
|
||||
const processCssContent = async (cssBaseDir: string, content: 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') {
|
||||
const replacements = new Map<string, string>();
|
||||
const replacementTasks: Promise<void>[] = [];
|
||||
declaration.value.replace(/url\((.*?)\)/g, (match: string, url: string) => {
|
||||
if (replacements.has(url)) return match;
|
||||
replacements.set(url, match);
|
||||
|
||||
replacementTasks.push((async () => {
|
||||
const cssFilePath = `${cssBaseDir}/${url}`;
|
||||
let replacement;
|
||||
if (await fs.exists(cssFilePath)) {
|
||||
replacement = `url(${await readFileDataUriSafe(cssFilePath)})`;
|
||||
} else {
|
||||
replacement = `url(${url})`;
|
||||
}
|
||||
replacements.set(url, replacement);
|
||||
})());
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
await Promise.all(replacementTasks);
|
||||
|
||||
declaration.value = declaration.value.replace(/url\((.*?)\)/g, (_match: string, url: string) => {
|
||||
return replacements.get(url);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cssStringify(o);
|
||||
};
|
||||
|
||||
const processLinkTag = async (_name: string, attrs: HtmlAttrs) => {
|
||||
const href = attrValue(attrs, 'href');
|
||||
if (!href) return null;
|
||||
|
||||
const filePath = `${baseDir}/${href}`;
|
||||
|
||||
if (!await fs.exists(filePath)) return null;
|
||||
const content = await fs.readFileText(filePath);
|
||||
return `<style>${await processCssContent(dirname(filePath), content)}</style>`;
|
||||
};
|
||||
|
||||
const processScriptTag = async (_name: string, attrs: HtmlAttrs) => {
|
||||
const src = attrValue(attrs, 'src');
|
||||
if (!src) return null;
|
||||
|
||||
const scriptFilePath = `${baseDir}/${src}`;
|
||||
if (!await fs.exists(scriptFilePath)) return null;
|
||||
let content = await fs.readFileText(scriptFilePath);
|
||||
|
||||
// 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>`;
|
||||
};
|
||||
|
||||
const processImgTag = async (_name: string, attrs: HtmlAttrs) => {
|
||||
const src = attrValue(attrs, 'src');
|
||||
if (!src) return null;
|
||||
|
||||
const filePath = `${baseDir}/${src}`;
|
||||
if (!await fs.exists(filePath)) return null;
|
||||
|
||||
const modAttrs = { ...attrs };
|
||||
delete modAttrs.src;
|
||||
return `<img src="${await readFileDataUriSafe(filePath)}" ${attributesHtml(modAttrs)}/>`;
|
||||
};
|
||||
|
||||
const isLocalHref = (href: string) => {
|
||||
if (href.startsWith('#')) return false;
|
||||
try { new URL(href); return false; } catch { return true; }
|
||||
};
|
||||
|
||||
const processAnchorTag = async (_name: string, attrs: HtmlAttrs) => {
|
||||
const href = attrValue(attrs, 'href');
|
||||
if (!href || !isLocalHref(href)) return null;
|
||||
|
||||
const filePath = `${baseDir}/${href}`;
|
||||
const modAttrs = { ...attrs };
|
||||
modAttrs.download = basename(href);
|
||||
|
||||
if (!await fs.exists(filePath)) {
|
||||
modAttrs.href = '';
|
||||
await fs.writeChunk(`<a ${attributesHtml(modAttrs)}>`);
|
||||
return '';
|
||||
}
|
||||
|
||||
delete modAttrs.href;
|
||||
await fs.writeChunk('<a href="');
|
||||
if (fs.streamFileDataUri) {
|
||||
await fs.streamFileDataUri(filePath, async (chunk) => { await fs.writeChunk(chunk); });
|
||||
} else {
|
||||
await fs.writeChunk(await readFileDataUriSafe(filePath));
|
||||
}
|
||||
await fs.writeChunk(`" ${attributesHtml(modAttrs)}>`);
|
||||
return '';
|
||||
};
|
||||
|
||||
interface Tag {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const tagStack: Tag[] = [];
|
||||
|
||||
const currentTag = () => {
|
||||
if (!tagStack.length) return { name: '', processed: false };
|
||||
return tagStack[tagStack.length - 1];
|
||||
};
|
||||
|
||||
await parseHtmlAsync(inputFileText, {
|
||||
onopentag: async (name: string, attrs: HtmlAttrs) => {
|
||||
name = name.toLowerCase();
|
||||
|
||||
let processedResult: string | null = null;
|
||||
|
||||
if (name === 'link') {
|
||||
processedResult = await processLinkTag(name, attrs);
|
||||
}
|
||||
|
||||
if (name === 'script') {
|
||||
processedResult = await processScriptTag(name, attrs);
|
||||
}
|
||||
|
||||
if (name === 'img') {
|
||||
processedResult = await processImgTag(name, attrs);
|
||||
}
|
||||
|
||||
if (name === 'a') {
|
||||
processedResult = await processAnchorTag(name, attrs);
|
||||
}
|
||||
|
||||
tagStack.push({ name });
|
||||
|
||||
if (processedResult === null) {
|
||||
let attrHtml = attributesHtml(attrs);
|
||||
if (attrHtml) attrHtml = ` ${attrHtml}`;
|
||||
const closingSign = isSelfClosingTag(name) ? '/>' : '>';
|
||||
await fs.writeChunk(`<${name}${attrHtml}${closingSign}`);
|
||||
|
||||
} else if (processedResult !== '') {
|
||||
await fs.writeChunk(processedResult);
|
||||
}
|
||||
},
|
||||
|
||||
ontext: async (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.
|
||||
await fs.writeChunk(decodedText);
|
||||
} else {
|
||||
await fs.writeChunk(htmlentities(decodedText));
|
||||
}
|
||||
},
|
||||
|
||||
onclosetag: async (name: string) => {
|
||||
const current = currentTag();
|
||||
|
||||
if (current.name === name.toLowerCase()) tagStack.pop();
|
||||
|
||||
if (isSelfClosingTag(name)) return;
|
||||
await fs.writeChunk(`</${name}>`);
|
||||
},
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
export default packToWriter;
|
||||
在新工单中引用
屏蔽一个用户