You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Chore: Refactor renderer package: Limit dependency on @joplin/lib and improve type safety (#9701)
				
					
				
			This commit is contained in:
		| @@ -734,6 +734,7 @@ packages/lib/models/utils/itemCanBeEncrypted.js | ||||
| packages/lib/models/utils/paginatedFeed.js | ||||
| packages/lib/models/utils/paginationToSql.js | ||||
| packages/lib/models/utils/readOnly.js | ||||
| packages/lib/models/utils/resourceUtils.js | ||||
| packages/lib/models/utils/types.js | ||||
| packages/lib/models/utils/userData.test.js | ||||
| packages/lib/models/utils/userData.js | ||||
| @@ -1056,13 +1057,15 @@ packages/renderer/MdToHtml/rules/sanitize_html.js | ||||
| packages/renderer/MdToHtml/rules/source_map.js | ||||
| packages/renderer/MdToHtml/setupLinkify.js | ||||
| packages/renderer/MdToHtml/validateLinks.js | ||||
| packages/renderer/assetsToHeaders.js | ||||
| packages/renderer/defaultResourceModel.js | ||||
| packages/renderer/headerAnchor.js | ||||
| packages/renderer/highlight.js | ||||
| packages/renderer/htmlUtils.test.js | ||||
| packages/renderer/htmlUtils.js | ||||
| packages/renderer/index.js | ||||
| packages/renderer/noteStyle.js | ||||
| packages/renderer/pathUtils.js | ||||
| packages/renderer/types.js | ||||
| packages/renderer/utils.js | ||||
| packages/tools/build-release-stats.test.js | ||||
| packages/tools/build-release-stats.js | ||||
|   | ||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -714,6 +714,7 @@ packages/lib/models/utils/itemCanBeEncrypted.js | ||||
| packages/lib/models/utils/paginatedFeed.js | ||||
| packages/lib/models/utils/paginationToSql.js | ||||
| packages/lib/models/utils/readOnly.js | ||||
| packages/lib/models/utils/resourceUtils.js | ||||
| packages/lib/models/utils/types.js | ||||
| packages/lib/models/utils/userData.test.js | ||||
| packages/lib/models/utils/userData.js | ||||
| @@ -1036,13 +1037,15 @@ packages/renderer/MdToHtml/rules/sanitize_html.js | ||||
| packages/renderer/MdToHtml/rules/source_map.js | ||||
| packages/renderer/MdToHtml/setupLinkify.js | ||||
| packages/renderer/MdToHtml/validateLinks.js | ||||
| packages/renderer/assetsToHeaders.js | ||||
| packages/renderer/defaultResourceModel.js | ||||
| packages/renderer/headerAnchor.js | ||||
| packages/renderer/highlight.js | ||||
| packages/renderer/htmlUtils.test.js | ||||
| packages/renderer/htmlUtils.js | ||||
| packages/renderer/index.js | ||||
| packages/renderer/noteStyle.js | ||||
| packages/renderer/pathUtils.js | ||||
| packages/renderer/types.js | ||||
| packages/renderer/utils.js | ||||
| packages/tools/build-release-stats.test.js | ||||
| packages/tools/build-release-stats.js | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
|  | ||||
| import MarkupToHtml, { MarkupLanguage, RenderResult } from '@joplin/renderer/MarkupToHtml'; | ||||
| import MarkupToHtml, { MarkupLanguage } from '@joplin/renderer/MarkupToHtml'; | ||||
| import { RenderResult } from '@joplin/renderer/types'; | ||||
|  | ||||
| describe('MarkupToHtml', () => { | ||||
|  | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigSc | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| const { connect } = require('react-redux'); | ||||
| const { themeStyle } = require('@joplin/lib/theme'); | ||||
| const pathUtils = require('@joplin/lib/path-utils'); | ||||
| import * as pathUtils from '@joplin/lib/path-utils'; | ||||
| import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; | ||||
| import * as shared from '@joplin/lib/components/shared/config/config-shared.js'; | ||||
| import ClipperConfigScreen from '../ClipperConfigScreen'; | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import AsyncActionQueue from '@joplin/lib/AsyncActionQueue'; | ||||
| import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; | ||||
| import { PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||
| import { MarkupLanguage } from '@joplin/renderer'; | ||||
| import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml'; | ||||
| import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/types'; | ||||
| import { MarkupToHtmlOptions } from './useMarkupToHtml'; | ||||
| import { Dispatch } from 'redux'; | ||||
| import { ProcessResultsRow } from '@joplin/lib/services/search/SearchEngine'; | ||||
|   | ||||
| @@ -5,7 +5,7 @@ const { themeStyle } = require('../../global-style.js'); | ||||
| import markupLanguageUtils from '@joplin/lib/markupLanguageUtils'; | ||||
| import useEditPopup from './useEditPopup'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| const { assetsToHeaders } = require('@joplin/renderer'); | ||||
| import { assetsToHeaders } from '@joplin/renderer'; | ||||
|  | ||||
| const logger = Logger.create('NoteBodyViewer/useSource'); | ||||
|  | ||||
|   | ||||
| @@ -7,9 +7,9 @@ import markdownUtils from '../markdownUtils'; | ||||
| import { _ } from '../locale'; | ||||
| import { ResourceEntity, ResourceLocalStateEntity, ResourceOcrStatus, SqlQuery } from '../services/database/types'; | ||||
| import ResourceLocalState from './ResourceLocalState'; | ||||
| const pathUtils = require('../path-utils'); | ||||
| import * as pathUtils from '../path-utils'; | ||||
| import { safeFilename } from '../path-utils'; | ||||
| const { mime } = require('../mime-utils.js'); | ||||
| const { filename, safeFilename } = require('../path-utils'); | ||||
| const { FsDriverDummy } = require('../fs-driver-dummy.js'); | ||||
| import JoplinError from '../JoplinError'; | ||||
| import itemCanBeEncrypted from './utils/itemCanBeEncrypted'; | ||||
| @@ -23,6 +23,7 @@ import { RecognizeResultLine } from '../services/ocr/utils/types'; | ||||
| import eventManager, { EventName } from '../eventManager'; | ||||
| import { unique } from '../array'; | ||||
| import isSqliteSyntaxError from '../services/database/isSqliteSyntaxError'; | ||||
| import { internalUrl, isResourceUrl, isSupportedImageMimeType, resourceFilename, resourceFullPath, resourcePathToId, resourceRelativePath, resourceUrlToId } from './utils/resourceUtils'; | ||||
|  | ||||
| export default class Resource extends BaseItem { | ||||
|  | ||||
| @@ -56,8 +57,7 @@ export default class Resource extends BaseItem { | ||||
| 	} | ||||
|  | ||||
| 	public static isSupportedImageMimeType(type: string) { | ||||
| 		const imageMimeTypes = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp', 'image/avif']; | ||||
| 		return imageMimeTypes.indexOf(type.toLowerCase()) >= 0; | ||||
| 		return isSupportedImageMimeType(type); | ||||
| 	} | ||||
|  | ||||
| 	public static fetchStatuses(resourceIds: string[]): Promise<any[]> { | ||||
| @@ -121,10 +121,7 @@ export default class Resource extends BaseItem { | ||||
| 	} | ||||
|  | ||||
| 	public static filename(resource: ResourceEntity, encryptedBlob = false) { | ||||
| 		let extension = encryptedBlob ? 'crypted' : resource.file_extension; | ||||
| 		if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : ''; | ||||
| 		extension = extension ? `.${extension}` : ''; | ||||
| 		return resource.id + extension; | ||||
| 		return resourceFilename(resource, encryptedBlob); | ||||
| 	} | ||||
|  | ||||
| 	public static friendlySafeFilename(resource: ResourceEntity) { | ||||
| @@ -137,11 +134,11 @@ export default class Resource extends BaseItem { | ||||
| 	} | ||||
|  | ||||
| 	public static relativePath(resource: ResourceEntity, encryptedBlob = false) { | ||||
| 		return `${Setting.value('resourceDirName')}/${this.filename(resource, encryptedBlob)}`; | ||||
| 		return resourceRelativePath(resource, this.baseRelativeDirectoryPath(), encryptedBlob); | ||||
| 	} | ||||
|  | ||||
| 	public static fullPath(resource: ResourceEntity, encryptedBlob = false) { | ||||
| 		return `${Setting.value('resourceDir')}/${this.filename(resource, encryptedBlob)}`; | ||||
| 		return resourceFullPath(resource, this.baseDirectoryPath(), encryptedBlob); | ||||
| 	} | ||||
|  | ||||
| 	public static async isReady(resource: ResourceEntity) { | ||||
| @@ -270,11 +267,11 @@ export default class Resource extends BaseItem { | ||||
| 	} | ||||
|  | ||||
| 	public static internalUrl(resource: ResourceEntity) { | ||||
| 		return `:/${resource.id}`; | ||||
| 		return internalUrl(resource); | ||||
| 	} | ||||
|  | ||||
| 	public static pathToId(path: string) { | ||||
| 		return filename(path); | ||||
| 		return resourcePathToId(path); | ||||
| 	} | ||||
|  | ||||
| 	public static async content(resource: ResourceEntity) { | ||||
| @@ -282,12 +279,11 @@ export default class Resource extends BaseItem { | ||||
| 	} | ||||
|  | ||||
| 	public static isResourceUrl(url: string) { | ||||
| 		return url && url.length === 34 && url[0] === ':' && url[1] === '/'; | ||||
| 		return isResourceUrl(url); | ||||
| 	} | ||||
|  | ||||
| 	public static urlToId(url: string) { | ||||
| 		if (!this.isResourceUrl(url)) throw new Error(`Not a valid resource URL: ${url}`); | ||||
| 		return url.substr(2); | ||||
| 		return resourceUrlToId(url); | ||||
| 	} | ||||
|  | ||||
| 	public static async localState(resourceOrId: any): Promise<ResourceLocalStateEntity> { | ||||
|   | ||||
							
								
								
									
										43
									
								
								packages/lib/models/utils/resourceUtils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								packages/lib/models/utils/resourceUtils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| import type { ResourceEntity } from '../../services/database/types'; | ||||
| const { mime } = require('../../mime-utils.js'); | ||||
| import { filename } from '@joplin/utils/path'; | ||||
|  | ||||
| // This file contains resource-related utilities that do not | ||||
| // depend on the database, settings, etc. | ||||
|  | ||||
| export const resourceFilename = (resource: ResourceEntity, encryptedBlob = false) => { | ||||
| 	let extension = encryptedBlob ? 'crypted' : resource.file_extension; | ||||
| 	if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : ''; | ||||
| 	extension = extension ? `.${extension}` : ''; | ||||
| 	return resource.id + extension; | ||||
| }; | ||||
|  | ||||
| export const resourceRelativePath = (resource: ResourceEntity, relativeResourceDirPath: string, encryptedBlob = false) => { | ||||
| 	return `${relativeResourceDirPath}/${resourceFilename(resource, encryptedBlob)}`; | ||||
| }; | ||||
|  | ||||
| export const resourceFullPath = (resource: ResourceEntity, resourceDirPath: string, encryptedBlob = false) => { | ||||
| 	return `${resourceDirPath}/${resourceFilename(resource, encryptedBlob)}`; | ||||
| }; | ||||
|  | ||||
| export const internalUrl = (resource: ResourceEntity) => { | ||||
| 	return `:/${resource.id}`; | ||||
| }; | ||||
|  | ||||
| export const resourcePathToId = (path: string) => { | ||||
| 	return filename(path); | ||||
| }; | ||||
|  | ||||
| export const isResourceUrl = (url: string) => { | ||||
| 	return url && url.length === 34 && url[0] === ':' && url[1] === '/'; | ||||
| }; | ||||
|  | ||||
| export const resourceUrlToId = (url: string) => { | ||||
| 	if (!isResourceUrl(url)) throw new Error(`Not a valid resource URL: ${url}`); | ||||
| 	return url.substring(2); | ||||
| }; | ||||
|  | ||||
| export const isSupportedImageMimeType = (type: string) => { | ||||
| 	const imageMimeTypes = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp', 'image/avif']; | ||||
| 	return imageMimeTypes.indexOf(type.toLowerCase()) >= 0; | ||||
| }; | ||||
| @@ -1,65 +1,8 @@ | ||||
| /* eslint no-useless-escape: 0*/ | ||||
|  | ||||
| const { _ } = require('./locale'); | ||||
|  | ||||
| export function dirname(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const s = path.split(/\/|\\/); | ||||
| 	s.pop(); | ||||
| 	return s.join('/'); | ||||
| } | ||||
|  | ||||
| export function basename(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const s = path.split(/\/|\\/); | ||||
| 	return s[s.length - 1]; | ||||
| } | ||||
|  | ||||
| export function filename(path: string, includeDir = false) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const output = includeDir ? path : basename(path); | ||||
| 	if (output.indexOf('.') < 0) return output; | ||||
|  | ||||
| 	const splitted = output.split('.'); | ||||
| 	splitted.pop(); | ||||
| 	return splitted.join('.'); | ||||
| } | ||||
|  | ||||
| export function fileExtension(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
|  | ||||
| 	const output = path.split('.'); | ||||
| 	if (output.length <= 1) return ''; | ||||
| 	return output[output.length - 1]; | ||||
| } | ||||
|  | ||||
| export function isHidden(path: string) { | ||||
| 	const b = basename(path); | ||||
| 	if (!b.length) throw new Error(`Path empty or not a valid path: ${path}`); | ||||
| 	return b[0] === '.'; | ||||
| } | ||||
|  | ||||
| // Note that this function only sanitizes a file extension - it does NOT extract | ||||
| // the file extension from a filename. So the way you'd normally call this is | ||||
| // `safeFileExtension(fileExtension(filename))` | ||||
| export function safeFileExtension(e: string, maxLength: number = null) { | ||||
| 	// In theory the file extension can have any length but in practice Joplin | ||||
| 	// expects a fixed length, so we limit it to 20 which should cover most cases. | ||||
| 	// Note that it means that a file extension longer than 20 will break | ||||
| 	// external editing (since the extension would be truncated). | ||||
| 	// https://discourse.joplinapp.org/t/troubles-with-webarchive-files-on-ios/10447 | ||||
| 	if (maxLength === null) maxLength = 20; | ||||
| 	if (!e || !e.replace) return ''; | ||||
| 	return e.replace(/[^a-zA-Z0-9]/g, '').substr(0, maxLength); | ||||
| } | ||||
|  | ||||
| export function safeFilename(e: string, maxLength: number = null, allowSpaces = false) { | ||||
| 	if (maxLength === null) maxLength = 32; | ||||
| 	if (!e || !e.replace) return ''; | ||||
| 	const regex = allowSpaces ? /[^a-zA-Z0-9\-_\(\)\. ]/g : /[^a-zA-Z0-9\-_\(\)\.]/g; | ||||
| 	const output = e.replace(regex, '_'); | ||||
| 	return output.substr(0, maxLength); | ||||
| } | ||||
| import { _ } from './locale'; | ||||
| import { fileExtension, filename, safeFileExtension } from '@joplin/utils/path'; | ||||
| export * from '@joplin/utils/path'; | ||||
|  | ||||
| let friendlySafeFilename_blackListChars = '/\n\r<>:\'"\\|?*#'; | ||||
| for (let i = 0; i < 32; i++) { | ||||
| @@ -129,77 +72,3 @@ export function friendlySafeFilename(e: string, maxLength: number = null, preser | ||||
|  | ||||
| 	return output.substr(0, maxLength) + fileExt; | ||||
| } | ||||
|  | ||||
| export function toFileProtocolPath(filePathEncode: string, os: string = null) { | ||||
| 	if (os === null) os = process.platform; | ||||
|  | ||||
| 	if (os === 'win32') { | ||||
| 		filePathEncode = filePathEncode.replace(/\\/g, '/'); // replace backslash in windows pathname with slash e.g. c:\temp to c:/temp | ||||
| 		filePathEncode = `/${filePathEncode}`; // put slash in front of path to comply with windows fileURL syntax | ||||
| 	} | ||||
|  | ||||
| 	filePathEncode = encodeURI(filePathEncode); | ||||
| 	filePathEncode = filePathEncode.replace(/\+/g, '%2B'); // escape '+' with unicode | ||||
| 	return `file://${filePathEncode.replace(/\'/g, '%27')}`; // escape '(single quote) with unicode, to prevent crashing the html view | ||||
| } | ||||
|  | ||||
| export function toSystemSlashes(path: string, os: string = null) { | ||||
| 	if (os === null) os = process.platform; | ||||
| 	if (os === 'win32') return path.replace(/\//g, '\\'); | ||||
| 	return path.replace(/\\/g, '/'); | ||||
| } | ||||
|  | ||||
| export function toForwardSlashes(path: string) { | ||||
| 	return toSystemSlashes(path, 'linux'); | ||||
| } | ||||
|  | ||||
| export function rtrimSlashes(path: string) { | ||||
| 	return path.replace(/[\/\\]+$/, ''); | ||||
| } | ||||
|  | ||||
| export function ltrimSlashes(path: string) { | ||||
| 	return path.replace(/^\/+/, ''); | ||||
| } | ||||
|  | ||||
| export function trimSlashes(path: string): string { | ||||
| 	return ltrimSlashes(rtrimSlashes(path)); | ||||
| } | ||||
|  | ||||
| export function quotePath(path: string) { | ||||
| 	if (!path) return ''; | ||||
| 	if (path.indexOf('"') < 0 && path.indexOf(' ') < 0) return path; | ||||
| 	path = path.replace(/"/, '\\"'); | ||||
| 	return `"${path}"`; | ||||
| } | ||||
|  | ||||
| export function unquotePath(path: string) { | ||||
| 	if (!path.length) return ''; | ||||
| 	if (path.length && path[0] === '"') { | ||||
| 		path = path.substr(1, path.length - 2); | ||||
| 	} | ||||
| 	path = path.replace(/\\"/, '"'); | ||||
| 	return path; | ||||
| } | ||||
|  | ||||
| export function extractExecutablePath(cmd: string) { | ||||
| 	if (!cmd.length) return ''; | ||||
|  | ||||
| 	const quoteType = ['"', '\''].indexOf(cmd[0]) >= 0 ? cmd[0] : ''; | ||||
|  | ||||
| 	let output = ''; | ||||
| 	for (let i = 0; i < cmd.length; i++) { | ||||
| 		const c = cmd[i]; | ||||
| 		if (quoteType) { | ||||
| 			if (i > 0 && c === quoteType) { | ||||
| 				output += c; | ||||
| 				break; | ||||
| 			} | ||||
| 		} else { | ||||
| 			if (c === ' ') break; | ||||
| 		} | ||||
|  | ||||
| 		output += c; | ||||
| 	} | ||||
|  | ||||
| 	return output; | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,7 @@ | ||||
| const { extractExecutablePath, quotePath, unquotePath, friendlySafeFilename, toFileProtocolPath } = require('./path-utils'); | ||||
| const { friendlySafeFilename } = require('./path-utils'); | ||||
|  | ||||
| describe('pathUtils', () => { | ||||
|  | ||||
|  | ||||
|  | ||||
| 	it('should create friendly safe filename', (async () => { | ||||
| 		const testCases = [ | ||||
| 			['生活', '生活'], | ||||
| @@ -32,59 +30,4 @@ describe('pathUtils', () => { | ||||
| 		expect(friendlySafeFilename('thatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylong.md', null, true)).toBe('thatsreallylongthatsreallylongthatsreallylongthats.md'); | ||||
| 	})); | ||||
|  | ||||
| 	it('should quote and unquote paths', (async () => { | ||||
| 		const testCases = [ | ||||
| 			['', ''], | ||||
| 			['/my/path', '/my/path'], | ||||
| 			['/my/path with spaces', '"/my/path with spaces"'], | ||||
| 			['/my/weird"path', '"/my/weird\\"path"'], | ||||
| 			['c:\\Windows\\test.dll', 'c:\\Windows\\test.dll'], | ||||
| 			['c:\\Windows\\test test.dll', '"c:\\Windows\\test test.dll"'], | ||||
| 		]; | ||||
|  | ||||
| 		for (let i = 0; i < testCases.length; i++) { | ||||
| 			const t = testCases[i]; | ||||
| 			expect(quotePath(t[0])).toBe(t[1]); | ||||
| 			expect(unquotePath(quotePath(t[0]))).toBe(t[0]); | ||||
| 		} | ||||
| 	})); | ||||
|  | ||||
| 	it('should extract executable path from command', (async () => { | ||||
| 		const testCases = [ | ||||
| 			['', ''], | ||||
| 			['/my/cmd -some -args', '/my/cmd'], | ||||
| 			['"/my/cmd" -some -args', '"/my/cmd"'], | ||||
| 			['"/my/cmd"', '"/my/cmd"'], | ||||
| 			['"/my/cmd and space" -some -flags', '"/my/cmd and space"'], | ||||
| 			['"" -some -flags', '""'], | ||||
| 		]; | ||||
|  | ||||
| 		for (let i = 0; i < testCases.length; i++) { | ||||
| 			const t = testCases[i]; | ||||
| 			expect(extractExecutablePath(t[0])).toBe(t[1]); | ||||
| 		} | ||||
| 	})); | ||||
|  | ||||
| 	it('should create correct fileURL syntax', (async () => { | ||||
| 		const testCases_win32 = [ | ||||
| 			['C:\\handle\\space test', 'file:///C:/handle/space%20test'], | ||||
| 			['C:\\escapeplus\\+', 'file:///C:/escapeplus/%2B'], | ||||
| 			['C:\\handle\\single quote\'', 'file:///C:/handle/single%20quote%27'], | ||||
| 		]; | ||||
| 		const testCases_unixlike = [ | ||||
| 			['/handle/space test', 'file:///handle/space%20test'], | ||||
| 			['/escapeplus/+', 'file:///escapeplus/%2B'], | ||||
| 			['/handle/single quote\'', 'file:///handle/single%20quote%27'], | ||||
| 		]; | ||||
|  | ||||
| 		for (let i = 0; i < testCases_win32.length; i++) { | ||||
| 			const t = testCases_win32[i]; | ||||
| 			expect(toFileProtocolPath(t[0], 'win32')).toBe(t[1]); | ||||
| 		} | ||||
| 		for (let i = 0; i < testCases_unixlike.length; i++) { | ||||
| 			const t = testCases_unixlike[i]; | ||||
| 			expect(toFileProtocolPath(t[0], 'linux')).toBe(t[1]); | ||||
| 		} | ||||
| 	})); | ||||
|  | ||||
| }); | ||||
|   | ||||
| @@ -12,7 +12,7 @@ import { basename, friendlySafeFilename, rtrimSlashes, dirname } from '../../pat | ||||
| import htmlpack from '@joplin/htmlpack'; | ||||
| const { themeStyle } = require('../../theme'); | ||||
| const { escapeHtml } = require('../../string-utils.js'); | ||||
| const { assetsToHeaders } = require('@joplin/renderer'); | ||||
| import { assetsToHeaders } from '@joplin/renderer'; | ||||
|  | ||||
| export default class InteropService_Exporter_Html extends InteropService_Exporter_Base { | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { PluginStates } from '../reducer'; | ||||
| import { ContentScriptType, ContentScriptContext, PostMessageHandler, ContentScriptModule } from '../api/types'; | ||||
| import { dirname } from '@joplin/renderer/pathUtils'; | ||||
| import { dirname } from '@joplin/utils/path'; | ||||
| import shim from '../../../shim'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import PluginService from '../PluginService'; | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import htmlUtils from './htmlUtils'; | ||||
| import linkReplacement from './MdToHtml/linkReplacement'; | ||||
| import utils, { ItemIdToUrlHandler } from './utils'; | ||||
| import * as utils from './utils'; | ||||
| import InMemoryCache from './InMemoryCache'; | ||||
| import { RenderResult } from './MarkupToHtml'; | ||||
| import noteStyle, { whiteBackgroundNoteStyle } from './noteStyle'; | ||||
| import { Options as NoteStyleOptions } from './noteStyle'; | ||||
| import { FsDriver, MarkupRenderer, OptionsResourceModel, RenderOptions, RenderResult } from './types'; | ||||
| const md5 = require('md5'); | ||||
|  | ||||
| // Renderered notes can potentially be quite large (for example | ||||
| @@ -17,38 +17,12 @@ export interface SplittedHtml { | ||||
| 	css: string; | ||||
| } | ||||
|  | ||||
| interface FsDriver { | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	writeFile: Function; | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	exists: Function; | ||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| 	cacheCssToFile: Function; | ||||
| } | ||||
|  | ||||
| interface Options { | ||||
| 	ResourceModel: any; | ||||
| 	ResourceModel: OptionsResourceModel; | ||||
| 	resourceBaseUrl?: string; | ||||
| 	fsDriver?: FsDriver; | ||||
| } | ||||
|  | ||||
| interface RenderOptions { | ||||
| 	splitted: boolean; | ||||
| 	bodyOnly: boolean; | ||||
| 	externalAssetsOnly: boolean; | ||||
| 	resources: any; | ||||
| 	postMessageSyntax: string; | ||||
| 	enableLongPress: boolean; | ||||
| 	itemIdToUrl?: ItemIdToUrlHandler; | ||||
| 	allowedFilePrefixes?: string[]; | ||||
| 	whiteBackgroundNoteRendering?: boolean; | ||||
|  | ||||
| 	// For compatibility with MdToHtml options: | ||||
| 	plugins?: { | ||||
| 		link_open?: { linkRenderingType?: number }; | ||||
| 	}; | ||||
| } | ||||
|  | ||||
| // https://github.com/es-shims/String.prototype.trimStart/blob/main/implementation.js | ||||
| function trimStart(s: string): string { | ||||
| 	// eslint-disable-next-line no-control-regex | ||||
| @@ -56,12 +30,12 @@ function trimStart(s: string): string { | ||||
| 	return s.replace(startWhitespace, ''); | ||||
| } | ||||
|  | ||||
| export default class HtmlToHtml { | ||||
| export default class HtmlToHtml implements MarkupRenderer { | ||||
|  | ||||
| 	private resourceBaseUrl_; | ||||
| 	private ResourceModel_; | ||||
| 	private cache_; | ||||
| 	private fsDriver_: any; | ||||
| 	private fsDriver_: FsDriver; | ||||
|  | ||||
| 	public constructor(options: Options = null) { | ||||
| 		options = { | ||||
| @@ -101,6 +75,10 @@ export default class HtmlToHtml { | ||||
| 		return [await this.fsDriver().cacheCssToFile(cssStrings)]; | ||||
| 	} | ||||
|  | ||||
| 	public clearCache(): void { | ||||
| 		// TODO: Clear the in-memory cache | ||||
| 	} | ||||
|  | ||||
| 	// Note: the "theme" variable is ignored and instead the light theme is | ||||
| 	// always used for HTML notes. | ||||
| 	// See: https://github.com/laurent22/joplin/issues/3698 | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import HtmlToHtml from './HtmlToHtml'; | ||||
| import htmlUtils from './htmlUtils'; | ||||
| import { Options as NoteStyleOptions } from './noteStyle'; | ||||
| import { AllHtmlEntities } from 'html-entities'; | ||||
| import { FsDriver, MarkupRenderer, MarkupToHtmlConverter, OptionsResourceModel, RenderResult } from './types'; | ||||
| import defaultResourceModel from './defaultResourceModel'; | ||||
| const MarkdownIt = require('markdown-it'); | ||||
|  | ||||
| export enum MarkupLanguage { | ||||
| @@ -11,29 +13,6 @@ export enum MarkupLanguage { | ||||
| 	Any = 3, | ||||
| } | ||||
|  | ||||
| export interface RenderResultPluginAsset { | ||||
| 	name: string; | ||||
| 	mime: string; | ||||
| 	path: string; | ||||
|  | ||||
| 	// For built-in Mardown-it plugins, the asset path is relative (and can be | ||||
| 	// found inside the @joplin/renderer package), while for external plugins | ||||
| 	// (content scripts), the path is absolute. We use this property to tell if | ||||
| 	// it's relative or absolute, as that will inform how it's loaded in various | ||||
| 	// places. | ||||
| 	pathIsAbsolute: boolean; | ||||
| } | ||||
|  | ||||
| export interface RenderResult { | ||||
| 	html: string; | ||||
| 	pluginAssets: RenderResultPluginAsset[]; | ||||
| 	cssStrings: string[]; | ||||
| } | ||||
|  | ||||
| export interface OptionsResourceModel { | ||||
| 	isResourceUrl: (url: string)=> boolean; | ||||
| } | ||||
|  | ||||
| export interface Options { | ||||
| 	isSafeMode?: boolean; | ||||
| 	ResourceModel?: OptionsResourceModel; | ||||
| @@ -42,23 +21,21 @@ export interface Options { | ||||
| 	resourceBaseUrl?: string; | ||||
| 	pluginOptions?: any; // Not sure if needed | ||||
| 	tempDir?: string; // Not sure if needed | ||||
| 	fsDriver?: any; // Not sure if needed | ||||
| 	fsDriver?: FsDriver; // Not sure if needed | ||||
| } | ||||
|  | ||||
| export default class MarkupToHtml { | ||||
| export default class MarkupToHtml implements MarkupToHtmlConverter { | ||||
|  | ||||
| 	public static MARKUP_LANGUAGE_MARKDOWN: number = MarkupLanguage.Markdown; | ||||
| 	public static MARKUP_LANGUAGE_HTML: number = MarkupLanguage.Html; | ||||
|  | ||||
| 	private renderers_: any = {}; | ||||
| 	private renderers_: Record<string, MarkupRenderer> = {}; | ||||
| 	private options_: Options; | ||||
| 	private rawMarkdownIt_: any; | ||||
|  | ||||
| 	public constructor(options: Options = null) { | ||||
| 		this.options_ = { | ||||
| 			ResourceModel: { | ||||
| 				isResourceUrl: () => false, | ||||
| 			}, | ||||
| 			ResourceModel: defaultResourceModel, | ||||
| 			isSafeMode: false, | ||||
| 			...options, | ||||
| 		}; | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import InMemoryCache from './InMemoryCache'; | ||||
| import noteStyle from './noteStyle'; | ||||
| import { fileExtension } from './pathUtils'; | ||||
| import { fileExtension } from '@joplin/utils/path'; | ||||
| import setupLinkify from './MdToHtml/setupLinkify'; | ||||
| import validateLinks from './MdToHtml/validateLinks'; | ||||
| import { ItemIdToUrlHandler } from './utils'; | ||||
| import { RenderResult, RenderResultPluginAsset } from './MarkupToHtml'; | ||||
| import { Options as NoteStyleOptions } from './noteStyle'; | ||||
| import { FsDriver, ItemIdToUrlHandler, MarkupRenderer, OptionsResourceModel, RenderOptions, RenderResult, RenderResultPluginAsset } from './types'; | ||||
| import hljs from './highlight'; | ||||
| import * as MarkdownIt from 'markdown-it'; | ||||
|  | ||||
| @@ -13,28 +12,6 @@ const Entities = require('html-entities').AllHtmlEntities; | ||||
| const htmlentities = new Entities().encode; | ||||
| const md5 = require('md5'); | ||||
|  | ||||
| export interface RenderOptions { | ||||
| 	contentMaxWidth?: number; | ||||
| 	bodyOnly?: boolean; | ||||
| 	splitted?: boolean; | ||||
| 	externalAssetsOnly?: boolean; | ||||
| 	postMessageSyntax?: string; | ||||
| 	highlightedKeywords?: string[]; | ||||
| 	codeTheme?: string; | ||||
| 	theme?: any; | ||||
| 	plugins?: Record<string, any>; | ||||
| 	audioPlayerEnabled?: boolean; | ||||
| 	videoPlayerEnabled?: boolean; | ||||
| 	pdfViewerEnabled?: boolean; | ||||
| 	codeHighlightCacheKey?: string; | ||||
| 	plainResourceRendering?: boolean; | ||||
| 	mapsToLine?: boolean; | ||||
| 	useCustomPdfViewer?: boolean; | ||||
| 	noteId?: string; | ||||
| 	vendorDir?: string; | ||||
| 	settingValue?: (pluginId: string, key: string)=> any; | ||||
| } | ||||
|  | ||||
| interface RendererRule { | ||||
| 	install(context: any, ruleOptions: any): any; | ||||
| 	assets?(theme: any): any; | ||||
| @@ -109,10 +86,10 @@ export interface ExtraRendererRule { | ||||
|  | ||||
| export interface Options { | ||||
| 	resourceBaseUrl?: string; | ||||
| 	ResourceModel?: any; | ||||
| 	ResourceModel?: OptionsResourceModel; | ||||
| 	pluginOptions?: any; | ||||
| 	tempDir?: string; | ||||
| 	fsDriver?: any; | ||||
| 	fsDriver?: FsDriver; | ||||
| 	extraRendererRules?: ExtraRendererRule[]; | ||||
| 	customCss?: string; | ||||
| } | ||||
| @@ -150,7 +127,7 @@ export interface RuleOptions { | ||||
| 	context: PluginContext; | ||||
| 	theme: any; | ||||
| 	postMessageSyntax: string; | ||||
| 	ResourceModel: any; | ||||
| 	ResourceModel: OptionsResourceModel; | ||||
| 	resourceBaseUrl: string; | ||||
| 	resources: any; // resourceId: Resource | ||||
|  | ||||
| @@ -199,12 +176,12 @@ export interface RuleOptions { | ||||
| 	platformName?: string; | ||||
| } | ||||
|  | ||||
| export default class MdToHtml { | ||||
| export default class MdToHtml implements MarkupRenderer { | ||||
|  | ||||
| 	private resourceBaseUrl_: string; | ||||
| 	private ResourceModel_: any; | ||||
| 	private ResourceModel_: OptionsResourceModel; | ||||
| 	private contextCache_: any; | ||||
| 	private fsDriver_: any; | ||||
| 	private fsDriver_: FsDriver; | ||||
|  | ||||
| 	private cachedOutputs_: any = {}; | ||||
| 	private lastCodeHighlightCacheKey_: string = null; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
|  | ||||
|  | ||||
| import utils from '../utils'; | ||||
| import * as utils from '../utils'; | ||||
|  | ||||
|  | ||||
| export interface Options { | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import defaultResourceModel from '../defaultResourceModel'; | ||||
| import linkReplacement from './linkReplacement'; | ||||
| import { describe, test, expect } from '@jest/globals'; | ||||
|  | ||||
| @@ -24,7 +25,7 @@ describe('linkReplacement', () => { | ||||
| 		const resourceId = 'f6afba55bdf74568ac94f8d1e3578d2c'; | ||||
|  | ||||
| 		const r = linkReplacement(`:/${resourceId}`, { | ||||
| 			ResourceModel: {}, | ||||
| 			ResourceModel: defaultResourceModel, | ||||
| 			resources: { | ||||
| 				[resourceId]: { | ||||
| 					item: {}, | ||||
| @@ -42,7 +43,7 @@ describe('linkReplacement', () => { | ||||
| 		const resourceId = 'f6afba55bdf74568ac94f8d1e3578d2c'; | ||||
|  | ||||
| 		const r = linkReplacement(`:/${resourceId}`, { | ||||
| 			ResourceModel: {}, | ||||
| 			ResourceModel: defaultResourceModel, | ||||
| 			resources: { | ||||
| 				[resourceId]: { | ||||
| 					item: {}, | ||||
| @@ -62,7 +63,7 @@ describe('linkReplacement', () => { | ||||
| 		const resourceId = 'e6afba55bdf74568ac94f8d1e3578d2c'; | ||||
|  | ||||
| 		const linkHtml = linkReplacement(`:/${resourceId}`, { | ||||
| 			ResourceModel: {}, | ||||
| 			ResourceModel: defaultResourceModel, | ||||
| 			resources: { | ||||
| 				[resourceId]: { | ||||
| 					item: {}, | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import utils, { ItemIdToUrlHandler } from '../utils'; | ||||
| import { ItemIdToUrlHandler, OptionsResourceModel } from '../types'; | ||||
| import * as utils from '../utils'; | ||||
| import createEventHandlingAttrs from './createEventHandlingAttrs'; | ||||
| const Entities = require('html-entities').AllHtmlEntities; | ||||
| const htmlentities = new Entities().encode; | ||||
| @@ -8,7 +9,7 @@ const { getClassNameForMimeType } = require('font-awesome-filetypes'); | ||||
| export interface Options { | ||||
| 	title?: string; | ||||
| 	resources?: any; | ||||
| 	ResourceModel?: any; | ||||
| 	ResourceModel?: OptionsResourceModel; | ||||
| 	linkRenderingType?: number; | ||||
| 	plainResourceRendering?: boolean; | ||||
| 	postMessageSyntax?: string; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Link } from '../MdToHtml'; | ||||
| import { toForwardSlashes } from '../pathUtils'; | ||||
| import { toForwardSlashes } from '@joplin/utils/path'; | ||||
| import { LinkIndexes } from './rules/link_close'; | ||||
| const Entities = require('html-entities').AllHtmlEntities; | ||||
| const htmlentities = new Entities().encode; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { RuleOptions } from '../../MdToHtml'; | ||||
| import { attributesHtml } from '../../htmlUtils'; | ||||
| import utils from '../../utils'; | ||||
| import * as utils from '../../utils'; | ||||
|  | ||||
| function renderImageHtml(before: string, src: string, after: string, ruleOptions: RuleOptions) { | ||||
| 	const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { RuleOptions } from '../../MdToHtml'; | ||||
| import { attributesHtml } from '../../htmlUtils'; | ||||
| import utils from '../../utils'; | ||||
| import * as utils from '../../utils'; | ||||
| import createEventHandlingAttrs from '../createEventHandlingAttrs'; | ||||
|  | ||||
| function plugin(markdownIt: any, ruleOptions: RuleOptions) { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { RuleOptions } from '../../MdToHtml'; | ||||
| import linkReplacement from '../linkReplacement'; | ||||
| import utils from '../../utils'; | ||||
| import * as utils from '../../utils'; | ||||
|  | ||||
| const urlUtils = require('../../urlUtils.js'); | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,15 @@ | ||||
| import { RenderResultPluginAsset } from './types'; | ||||
| 
 | ||||
| interface Options { | ||||
| 	asHtml: boolean; | ||||
| } | ||||
| 
 | ||||
| // Utility function to convert the plugin assets to a list of LINK or SCRIPT tags
 | ||||
| // that can be included in the HEAD tag.
 | ||||
| function assetsToHeaders(pluginAssets, options = null) { | ||||
| const assetsToHeaders = (pluginAssets: RenderResultPluginAsset[], options: Options|null = null) => { | ||||
| 	options = { asHtml: false, ...options }; | ||||
| 
 | ||||
| 	const headers = {}; | ||||
| 	const headers: Record<string, string> = {}; | ||||
| 	for (let i = 0; i < pluginAssets.length; i++) { | ||||
| 		const asset = pluginAssets[i]; | ||||
| 		if (asset.mime === 'text/css') { | ||||
| @@ -23,6 +29,6 @@ function assetsToHeaders(pluginAssets, options = null) { | ||||
| 	} | ||||
| 
 | ||||
| 	return headers; | ||||
| } | ||||
| }; | ||||
| 
 | ||||
| module.exports = assetsToHeaders; | ||||
| export default assetsToHeaders; | ||||
							
								
								
									
										16
									
								
								packages/renderer/defaultResourceModel.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								packages/renderer/defaultResourceModel.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import { OptionsResourceModel } from './types'; | ||||
|  | ||||
| // Used for tests and when no ResourceModel is provided. | ||||
|  | ||||
| const defaultResourceModel: OptionsResourceModel = { | ||||
| 	isResourceUrl: (_url: string) => false, | ||||
| 	urlToId: _url => { | ||||
| 		throw new Error('Not implemented: urlToId'); | ||||
| 	}, | ||||
| 	filename: _url => { | ||||
| 		throw new Error('Not implemented: filename'); | ||||
| 	}, | ||||
| 	isSupportedImageMimeType: _type => false, | ||||
| }; | ||||
|  | ||||
| export default defaultResourceModel; | ||||
| @@ -1,11 +1,11 @@ | ||||
| import MarkupToHtml, { MarkupLanguage } from './MarkupToHtml'; | ||||
| import MdToHtml from './MdToHtml'; | ||||
| import HtmlToHtml from './HtmlToHtml'; | ||||
| import utils from './utils'; | ||||
| import * as utils from './utils'; | ||||
| import setupLinkify from './MdToHtml/setupLinkify'; | ||||
| import validateLinks from './MdToHtml/validateLinks'; | ||||
| import headerAnchor from './headerAnchor'; | ||||
| const assetsToHeaders = require('./assetsToHeaders'); | ||||
| import assetsToHeaders from './assetsToHeaders'; | ||||
|  | ||||
| export { | ||||
| 	MarkupToHtml, | ||||
|   | ||||
| @@ -1,34 +0,0 @@ | ||||
| export function dirname(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const s = path.split(/\/|\\/); | ||||
| 	s.pop(); | ||||
| 	return s.join('/'); | ||||
| } | ||||
|  | ||||
| export function basename(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const s = path.split(/\/|\\/); | ||||
| 	return s[s.length - 1]; | ||||
| } | ||||
|  | ||||
| export function filename(path: string, includeDir = false): string { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const output = includeDir ? path : basename(path); | ||||
| 	if (output.indexOf('.') < 0) return output; | ||||
|  | ||||
| 	const splitted = output.split('.'); | ||||
| 	splitted.pop(); | ||||
| 	return splitted.join('.'); | ||||
| } | ||||
|  | ||||
| export function fileExtension(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
|  | ||||
| 	const output = path.split('.'); | ||||
| 	if (output.length <= 1) return ''; | ||||
| 	return output[output.length - 1]; | ||||
| } | ||||
|  | ||||
| export function toForwardSlashes(path: string) { | ||||
| 	return path.replace(/\\/g, '/'); | ||||
| } | ||||
							
								
								
									
										95
									
								
								packages/renderer/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								packages/renderer/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| import { MarkupLanguage } from './MarkupToHtml'; | ||||
| import { Options as NoteStyleOptions } from './noteStyle'; | ||||
|  | ||||
| export type ItemIdToUrlHandler = (resource: any)=> string; | ||||
|  | ||||
| interface ResourceEntity { | ||||
| 	id: string; | ||||
| 	title?: string; | ||||
| 	mime?: string; | ||||
| 	file_extension?: string; | ||||
| } | ||||
|  | ||||
| export interface FsDriver { | ||||
| 	writeFile: (path: string, content: string, encoding: string)=> Promise<void>; | ||||
| 	exists: (path: string)=> Promise<boolean>; | ||||
| 	cacheCssToFile: (cssStrings: string[])=> Promise<any>; | ||||
| } | ||||
|  | ||||
| export interface RenderOptions { | ||||
| 	contentMaxWidth?: number; | ||||
| 	bodyOnly?: boolean; | ||||
| 	splitted?: boolean; | ||||
| 	enableLongPress?: boolean; | ||||
| 	postMessageSyntax?: string; | ||||
|  | ||||
| 	externalAssetsOnly?: boolean; | ||||
| 	highlightedKeywords?: string[]; | ||||
| 	codeTheme?: string; | ||||
| 	theme?: any; | ||||
|  | ||||
| 	plugins?: Record<string, any>; | ||||
| 	audioPlayerEnabled?: boolean; | ||||
| 	videoPlayerEnabled?: boolean; | ||||
| 	pdfViewerEnabled?: boolean; | ||||
|  | ||||
| 	codeHighlightCacheKey?: string; | ||||
| 	plainResourceRendering?: boolean; | ||||
|  | ||||
| 	mapsToLine?: boolean; | ||||
| 	useCustomPdfViewer?: boolean; | ||||
| 	noteId?: string; | ||||
| 	vendorDir?: string; | ||||
| 	itemIdToUrl?: ItemIdToUrlHandler; | ||||
| 	allowedFilePrefixes?: string[]; | ||||
| 	settingValue?: (pluginId: string, key: string)=> any; | ||||
|  | ||||
| 	resources?: Record<string, ResourceEntity>; | ||||
|  | ||||
| 	// HtmlToHtml only | ||||
| 	whiteBackgroundNoteRendering?: boolean; | ||||
| } | ||||
|  | ||||
| export interface RenderResultPluginAsset { | ||||
| 	name: string; | ||||
| 	mime: string; | ||||
| 	path: string; | ||||
|  | ||||
| 	// For built-in Mardown-it plugins, the asset path is relative (and can be | ||||
| 	// found inside the @joplin/renderer package), while for external plugins | ||||
| 	// (content scripts), the path is absolute. We use this property to tell if | ||||
| 	// it's relative or absolute, as that will inform how it's loaded in various | ||||
| 	// places. | ||||
| 	pathIsAbsolute: boolean; | ||||
| } | ||||
|  | ||||
| export interface RenderResult { | ||||
| 	html: string; | ||||
| 	pluginAssets: RenderResultPluginAsset[]; | ||||
| 	cssStrings: string[]; | ||||
| } | ||||
|  | ||||
| export interface MarkupRenderer { | ||||
| 	render(markup: string, theme: any, options: RenderOptions): Promise<RenderResult>; | ||||
| 	clearCache(): void; | ||||
| 	allAssets(theme: any, noteStyleOptions: NoteStyleOptions|null): Promise<RenderResultPluginAsset[]>; | ||||
| } | ||||
|  | ||||
| interface StripMarkupOptions { | ||||
| 	collapseWhiteSpaces: boolean; | ||||
| } | ||||
|  | ||||
| export interface MarkupToHtmlConverter { | ||||
| 	render(markupLanguage: MarkupLanguage, markup: string, theme: any, options: any): Promise<RenderResult>; | ||||
| 	clearCache(markupLanguage: MarkupLanguage): void; | ||||
| 	stripMarkup(markupLanguage: MarkupLanguage, markup: string, options: StripMarkupOptions): string; | ||||
| 	allAssets(markupLanguage: MarkupLanguage, theme: any, noteStyleOptions: NoteStyleOptions|null): Promise<RenderResultPluginAsset[]>; | ||||
| } | ||||
|  | ||||
| export interface OptionsResourceModel { | ||||
| 	isResourceUrl: (url: string)=> boolean; | ||||
| 	urlToId: (url: string)=> string; | ||||
| 	filename: (resource: ResourceEntity, encryptedBlob?: boolean)=> string; | ||||
| 	isSupportedImageMimeType: (type: string)=> boolean; | ||||
| 	fullPath?: (resource: ResourceEntity, encryptedBlob?: boolean)=> string; | ||||
| } | ||||
| @@ -1,3 +1,5 @@ | ||||
| import { ItemIdToUrlHandler, OptionsResourceModel } from './types'; | ||||
|  | ||||
| const Entities = require('html-entities').AllHtmlEntities; | ||||
| const htmlentities = new Entities().encode; | ||||
|  | ||||
| @@ -9,16 +11,14 @@ const FetchStatuses = { | ||||
| 	FETCH_STATUS_ERROR: 3, | ||||
| }; | ||||
|  | ||||
| const utils: any = {}; | ||||
|  | ||||
| utils.getAttr = function(attrs: string[], name: string, defaultValue: string = null) { | ||||
| export const getAttr = function(attrs: string[], name: string, defaultValue: string = null) { | ||||
| 	for (let i = 0; i < attrs.length; i++) { | ||||
| 		if (attrs[i][0] === name) return attrs[i].length > 1 ? attrs[i][1] : null; | ||||
| 	} | ||||
| 	return defaultValue; | ||||
| }; | ||||
|  | ||||
| utils.notDownloadedResource = function() { | ||||
| export const notDownloadedResource = function() { | ||||
| 	return ` | ||||
| 		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg"> | ||||
| 		    <path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/> | ||||
| @@ -26,7 +26,7 @@ utils.notDownloadedResource = function() { | ||||
| 	`; | ||||
| }; | ||||
|  | ||||
| utils.notDownloadedImage = function() { | ||||
| export const notDownloadedImage = function() { | ||||
| 	// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/file-image-o.svg | ||||
| 	// Height changed to 1795 | ||||
| 	return ` | ||||
| @@ -36,7 +36,7 @@ utils.notDownloadedImage = function() { | ||||
| 	`; | ||||
| }; | ||||
|  | ||||
| utils.notDownloadedFile = function() { | ||||
| export const notDownloadedFile = function() { | ||||
| 	// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/file-o.svg | ||||
| 	return ` | ||||
| 		<svg width="1925" height="1792" xmlns="http://www.w3.org/2000/svg"> | ||||
| @@ -45,7 +45,7 @@ utils.notDownloadedFile = function() { | ||||
| 	`; | ||||
| }; | ||||
|  | ||||
| utils.errorImage = function() { | ||||
| export const errorImage = function() { | ||||
| 	// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/times-circle.svg | ||||
| 	return ` | ||||
| 		<svg width="1795" height="1795" xmlns="http://www.w3.org/2000/svg"> | ||||
| @@ -54,7 +54,7 @@ utils.errorImage = function() { | ||||
| 	`; | ||||
| }; | ||||
|  | ||||
| utils.loaderImage = function() { | ||||
| export const loaderImage = function() { | ||||
| 	// https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/hourglass-half.svg | ||||
| 	return ` | ||||
| 		<svg width="1536" height="1790" xmlns="http://www.w3.org/2000/svg"> | ||||
| @@ -63,21 +63,21 @@ utils.loaderImage = function() { | ||||
| 	`; | ||||
| }; | ||||
|  | ||||
| utils.resourceStatusImage = function(status: string) { | ||||
| 	if (status === 'notDownloaded') return utils.notDownloadedResource(); | ||||
| 	return utils.resourceStatusFile(status); | ||||
| export const resourceStatusImage = function(status: string) { | ||||
| 	if (status === 'notDownloaded') return notDownloadedResource(); | ||||
| 	return resourceStatusFile(status); | ||||
| }; | ||||
|  | ||||
| utils.resourceStatusFile = function(status: string) { | ||||
| 	if (status === 'notDownloaded') return utils.notDownloadedResource(); | ||||
| 	if (status === 'downloading') return utils.loaderImage(); | ||||
| 	if (status === 'encrypted') return utils.loaderImage(); | ||||
| 	if (status === 'error') return utils.errorImage(); | ||||
| export const resourceStatusFile = function(status: string) { | ||||
| 	if (status === 'notDownloaded') return notDownloadedResource(); | ||||
| 	if (status === 'downloading') return loaderImage(); | ||||
| 	if (status === 'encrypted') return loaderImage(); | ||||
| 	if (status === 'error') return errorImage(); | ||||
|  | ||||
| 	throw new Error(`Unknown status: ${status}`); | ||||
| }; | ||||
|  | ||||
| utils.resourceStatusIndex = function(status: string) { | ||||
| export const resourceStatusIndex = function(status: string) { | ||||
| 	if (status === 'error') return -1; | ||||
| 	if (status === 'notDownloaded') return 0; | ||||
| 	if (status === 'downloading') return 1; | ||||
| @@ -87,7 +87,7 @@ utils.resourceStatusIndex = function(status: string) { | ||||
| 	throw new Error(`Unknown status: ${status}`); | ||||
| }; | ||||
|  | ||||
| utils.resourceStatusName = function(index: number) { | ||||
| export const resourceStatusName = function(index: number) { | ||||
| 	if (index === -1) return 'error'; | ||||
| 	if (index === 0) return 'notDownloaded'; | ||||
| 	if (index === 1) return 'downloading'; | ||||
| @@ -97,34 +97,32 @@ utils.resourceStatusName = function(index: number) { | ||||
| 	throw new Error(`Unknown index: ${index}`); | ||||
| }; | ||||
|  | ||||
| utils.resourceStatus = function(ResourceModel: any, resourceInfo: any) { | ||||
| export const resourceStatus = function(ResourceModel: OptionsResourceModel, resourceInfo: any) { | ||||
| 	if (!ResourceModel) return 'ready'; | ||||
|  | ||||
| 	let resourceStatus = 'ready'; | ||||
| 	let status = 'ready'; | ||||
|  | ||||
| 	if (resourceInfo) { | ||||
| 		const resource = resourceInfo.item; | ||||
| 		const localState = resourceInfo.localState; | ||||
|  | ||||
| 		if (localState.fetch_status === FetchStatuses.FETCH_STATUS_IDLE) { | ||||
| 			resourceStatus = 'notDownloaded'; | ||||
| 			status = 'notDownloaded'; | ||||
| 		} else if (localState.fetch_status === FetchStatuses.FETCH_STATUS_STARTED) { | ||||
| 			resourceStatus = 'downloading'; | ||||
| 			status = 'downloading'; | ||||
| 		} else if (localState.fetch_status === FetchStatuses.FETCH_STATUS_DONE) { | ||||
| 			if (resource.encryption_blob_encrypted || resource.encryption_applied) { | ||||
| 				resourceStatus = 'encrypted'; | ||||
| 				status = 'encrypted'; | ||||
| 			} | ||||
| 		} | ||||
| 	} else { | ||||
| 		resourceStatus = 'notDownloaded'; | ||||
| 		status = 'notDownloaded'; | ||||
| 	} | ||||
|  | ||||
| 	return resourceStatus; | ||||
| 	return status; | ||||
| }; | ||||
|  | ||||
| export type ItemIdToUrlHandler = (resource: any)=> string; | ||||
|  | ||||
| utils.imageReplacement = function(ResourceModel: any, src: string, resources: any, resourceBaseUrl: string, itemIdToUrl: ItemIdToUrlHandler = null) { | ||||
| export const imageReplacement = function(ResourceModel: OptionsResourceModel, src: string, resources: any, resourceBaseUrl: string, itemIdToUrl: ItemIdToUrlHandler = null) { | ||||
| 	if (!ResourceModel || !resources) return null; | ||||
|  | ||||
| 	if (!ResourceModel.isResourceUrl(src)) return null; | ||||
| @@ -132,11 +130,11 @@ utils.imageReplacement = function(ResourceModel: any, src: string, resources: an | ||||
| 	const resourceId = ResourceModel.urlToId(src); | ||||
| 	const result = resources[resourceId]; | ||||
| 	const resource = result ? result.item : null; | ||||
| 	const resourceStatus = utils.resourceStatus(ResourceModel, result); | ||||
| 	const status = resourceStatus(ResourceModel, result); | ||||
|  | ||||
| 	if (resourceStatus !== 'ready') { | ||||
| 		const icon = utils.resourceStatusImage(resourceStatus); | ||||
| 		return `<div class="not-loaded-resource resource-status-${resourceStatus}" data-resource-id="${resourceId}">` + `<img src="data:image/svg+xml;utf8,${htmlentities(icon)}"/>` + '</div>'; | ||||
| 	if (status !== 'ready') { | ||||
| 		const icon = resourceStatusImage(status); | ||||
| 		return `<div class="not-loaded-resource resource-status-${status}" data-resource-id="${resourceId}">` + `<img src="data:image/svg+xml;utf8,${htmlentities(icon)}"/>` + '</div>'; | ||||
| 	} | ||||
| 	const mime = resource.mime ? resource.mime.toLowerCase() : ''; | ||||
| 	if (ResourceModel.isSupportedImageMimeType(mime)) { | ||||
| @@ -172,6 +170,4 @@ utils.imageReplacement = function(ResourceModel: any, src: string, resources: an | ||||
|  | ||||
| // Used in mobile app when enableLongPress = true. Tells for how long | ||||
| // the resource should be pressed before the menu is shown. | ||||
| utils.longPressDelay = 500; | ||||
|  | ||||
| export default utils; | ||||
| export const longPressDelay = 500; | ||||
|   | ||||
| @@ -16,7 +16,7 @@ import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import { formatDateTime } from './time'; | ||||
| import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors'; | ||||
| import { MarkupToHtml } from '@joplin/renderer'; | ||||
| import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml'; | ||||
| import { OptionsResourceModel } from '@joplin/renderer/types'; | ||||
| import { isValidHeaderIdentifier } from '@joplin/lib/services/e2ee/EncryptionService'; | ||||
| const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js'); | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
|   | ||||
| @@ -13,7 +13,8 @@ | ||||
|     "./net": "./dist/net.js", | ||||
|     "./time": "./dist/time.js", | ||||
|     "./types": "./dist/types.js", | ||||
|     "./url": "./dist/url.js" | ||||
|     "./url": "./dist/url.js", | ||||
|     "./path": "./dist/path.js" | ||||
|   }, | ||||
|   "publishConfig": { | ||||
|     "access": "public" | ||||
|   | ||||
							
								
								
									
										58
									
								
								packages/utils/path.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								packages/utils/path.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| import { extractExecutablePath, quotePath, toFileProtocolPath, unquotePath } from './path'; | ||||
|  | ||||
| describe('path', () => { | ||||
| 	it('should quote and unquote paths', (async () => { | ||||
| 		const testCases = [ | ||||
| 			['', ''], | ||||
| 			['/my/path', '/my/path'], | ||||
| 			['/my/path with spaces', '"/my/path with spaces"'], | ||||
| 			['/my/weird"path', '"/my/weird\\"path"'], | ||||
| 			['c:\\Windows\\test.dll', 'c:\\Windows\\test.dll'], | ||||
| 			['c:\\Windows\\test test.dll', '"c:\\Windows\\test test.dll"'], | ||||
| 		]; | ||||
|  | ||||
| 		for (let i = 0; i < testCases.length; i++) { | ||||
| 			const t = testCases[i]; | ||||
| 			expect(quotePath(t[0])).toBe(t[1]); | ||||
| 			expect(unquotePath(quotePath(t[0]))).toBe(t[0]); | ||||
| 		} | ||||
| 	})); | ||||
|  | ||||
| 	it('should extract executable path from command', (async () => { | ||||
| 		const testCases = [ | ||||
| 			['', ''], | ||||
| 			['/my/cmd -some -args', '/my/cmd'], | ||||
| 			['"/my/cmd" -some -args', '"/my/cmd"'], | ||||
| 			['"/my/cmd"', '"/my/cmd"'], | ||||
| 			['"/my/cmd and space" -some -flags', '"/my/cmd and space"'], | ||||
| 			['"" -some -flags', '""'], | ||||
| 		]; | ||||
|  | ||||
| 		for (let i = 0; i < testCases.length; i++) { | ||||
| 			const t = testCases[i]; | ||||
| 			expect(extractExecutablePath(t[0])).toBe(t[1]); | ||||
| 		} | ||||
| 	})); | ||||
|  | ||||
| 	it('should create correct fileURL syntax', (async () => { | ||||
| 		const testCases_win32 = [ | ||||
| 			['C:\\handle\\space test', 'file:///C:/handle/space%20test'], | ||||
| 			['C:\\escapeplus\\+', 'file:///C:/escapeplus/%2B'], | ||||
| 			['C:\\handle\\single quote\'', 'file:///C:/handle/single%20quote%27'], | ||||
| 		]; | ||||
| 		const testCases_unixlike = [ | ||||
| 			['/handle/space test', 'file:///handle/space%20test'], | ||||
| 			['/escapeplus/+', 'file:///escapeplus/%2B'], | ||||
| 			['/handle/single quote\'', 'file:///handle/single%20quote%27'], | ||||
| 		]; | ||||
|  | ||||
| 		for (let i = 0; i < testCases_win32.length; i++) { | ||||
| 			const t = testCases_win32[i]; | ||||
| 			expect(toFileProtocolPath(t[0], 'win32')).toBe(t[1]); | ||||
| 		} | ||||
| 		for (let i = 0; i < testCases_unixlike.length; i++) { | ||||
| 			const t = testCases_unixlike[i]; | ||||
| 			expect(toFileProtocolPath(t[0], 'linux')).toBe(t[1]); | ||||
| 		} | ||||
| 	})); | ||||
| }); | ||||
							
								
								
									
										134
									
								
								packages/utils/path.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										134
									
								
								packages/utils/path.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,134 @@ | ||||
| /* eslint no-useless-escape: 0*/ | ||||
|  | ||||
| export function dirname(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const s = path.split(/\/|\\/); | ||||
| 	s.pop(); | ||||
| 	return s.join('/'); | ||||
| } | ||||
|  | ||||
| export function basename(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const s = path.split(/\/|\\/); | ||||
| 	return s[s.length - 1]; | ||||
| } | ||||
|  | ||||
| export function filename(path: string, includeDir = false) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
| 	const output = includeDir ? path : basename(path); | ||||
| 	if (output.indexOf('.') < 0) return output; | ||||
|  | ||||
| 	const splitted = output.split('.'); | ||||
| 	splitted.pop(); | ||||
| 	return splitted.join('.'); | ||||
| } | ||||
|  | ||||
| export function fileExtension(path: string) { | ||||
| 	if (!path) throw new Error('Path is empty'); | ||||
|  | ||||
| 	const output = path.split('.'); | ||||
| 	if (output.length <= 1) return ''; | ||||
| 	return output[output.length - 1]; | ||||
| } | ||||
|  | ||||
| export function isHidden(path: string) { | ||||
| 	const b = basename(path); | ||||
| 	if (!b.length) throw new Error(`Path empty or not a valid path: ${path}`); | ||||
| 	return b[0] === '.'; | ||||
| } | ||||
|  | ||||
| // Note that this function only sanitizes a file extension - it does NOT extract | ||||
| // the file extension from a filename. So the way you'd normally call this is | ||||
| // `safeFileExtension(fileExtension(filename))` | ||||
| export function safeFileExtension(e: string, maxLength: number|null = null) { | ||||
| 	// In theory the file extension can have any length but in practice Joplin | ||||
| 	// expects a fixed length, so we limit it to 20 which should cover most cases. | ||||
| 	// Note that it means that a file extension longer than 20 will break | ||||
| 	// external editing (since the extension would be truncated). | ||||
| 	// https://discourse.joplinapp.org/t/troubles-with-webarchive-files-on-ios/10447 | ||||
| 	if (maxLength === null) maxLength = 20; | ||||
| 	if (!e || !e.replace) return ''; | ||||
| 	return e.replace(/[^a-zA-Z0-9]/g, '').substring(0, maxLength); | ||||
| } | ||||
|  | ||||
| export function safeFilename(e: string, maxLength: number|null = null, allowSpaces = false) { | ||||
| 	if (maxLength === null) maxLength = 32; | ||||
| 	if (!e || !e.replace) return ''; | ||||
| 	const regex = allowSpaces ? /[^a-zA-Z0-9\-_\(\)\. ]/g : /[^a-zA-Z0-9\-_\(\)\.]/g; | ||||
| 	const output = e.replace(regex, '_'); | ||||
| 	return output.substring(0, maxLength); | ||||
| } | ||||
|  | ||||
| export function toFileProtocolPath(filePathEncode: string, os: string|null = null) { | ||||
| 	if (os === null) os = process.platform; | ||||
|  | ||||
| 	if (os === 'win32') { | ||||
| 		filePathEncode = filePathEncode.replace(/\\/g, '/'); // replace backslash in windows pathname with slash e.g. c:\temp to c:/temp | ||||
| 		filePathEncode = `/${filePathEncode}`; // put slash in front of path to comply with windows fileURL syntax | ||||
| 	} | ||||
|  | ||||
| 	filePathEncode = encodeURI(filePathEncode); | ||||
| 	filePathEncode = filePathEncode.replace(/\+/g, '%2B'); // escape '+' with unicode | ||||
| 	return `file://${filePathEncode.replace(/\'/g, '%27')}`; // escape '(single quote) with unicode, to prevent crashing the html view | ||||
| } | ||||
|  | ||||
| export function toSystemSlashes(path: string, os: string|null = null) { | ||||
| 	if (os === null) os = process.platform; | ||||
| 	if (os === 'win32') return path.replace(/\//g, '\\'); | ||||
| 	return path.replace(/\\/g, '/'); | ||||
| } | ||||
|  | ||||
| export function toForwardSlashes(path: string) { | ||||
| 	return toSystemSlashes(path, 'linux'); | ||||
| } | ||||
|  | ||||
| export function rtrimSlashes(path: string) { | ||||
| 	return path.replace(/[\/\\]+$/, ''); | ||||
| } | ||||
|  | ||||
| export function ltrimSlashes(path: string) { | ||||
| 	return path.replace(/^\/+/, ''); | ||||
| } | ||||
|  | ||||
| export function trimSlashes(path: string): string { | ||||
| 	return ltrimSlashes(rtrimSlashes(path)); | ||||
| } | ||||
|  | ||||
| export function quotePath(path: string) { | ||||
| 	if (!path) return ''; | ||||
| 	if (path.indexOf('"') < 0 && path.indexOf(' ') < 0) return path; | ||||
| 	path = path.replace(/"/, '\\"'); | ||||
| 	return `"${path}"`; | ||||
| } | ||||
|  | ||||
| export function unquotePath(path: string) { | ||||
| 	if (!path.length) return ''; | ||||
| 	if (path.length && path[0] === '"') { | ||||
| 		path = path.substring(1, path.length - 1); | ||||
| 	} | ||||
| 	path = path.replace(/\\"/, '"'); | ||||
| 	return path; | ||||
| } | ||||
|  | ||||
| export function extractExecutablePath(cmd: string) { | ||||
| 	if (!cmd.length) return ''; | ||||
|  | ||||
| 	const quoteType = ['"', '\''].indexOf(cmd[0]) >= 0 ? cmd[0] : ''; | ||||
|  | ||||
| 	let output = ''; | ||||
| 	for (let i = 0; i < cmd.length; i++) { | ||||
| 		const c = cmd[i]; | ||||
| 		if (quoteType) { | ||||
| 			if (i > 0 && c === quoteType) { | ||||
| 				output += c; | ||||
| 				break; | ||||
| 			} | ||||
| 		} else { | ||||
| 			if (c === ' ') break; | ||||
| 		} | ||||
|  | ||||
| 		output += c; | ||||
| 	} | ||||
|  | ||||
| 	return output; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user