1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-27 08:21:03 +02:00

Chore: Refactor renderer package: Limit dependency on @joplin/lib and improve type safety (#9701)

This commit is contained in:
Henry Heino 2024-01-18 03:20:10 -08:00 committed by GitHub
parent 352ee6496e
commit f5e1e45f6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 457 additions and 393 deletions

View File

@ -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
View File

@ -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

View File

@ -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', () => {

View File

@ -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';

View File

@ -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';

View File

@ -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');

View File

@ -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> {

View 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;
};

View File

@ -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;
}

View File

@ -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]);
}
}));
});

View File

@ -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 {

View File

@ -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';

View File

@ -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

View File

@ -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,
};

View File

@ -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;

View File

@ -1,6 +1,6 @@
import utils from '../utils';
import * as utils from '../utils';
export interface Options {

View File

@ -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: {},

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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) {

View File

@ -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');

View File

@ -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;

View 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;

View File

@ -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,

View File

@ -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, '/');
}

View 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;
}

View File

@ -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;

View File

@ -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';

View File

@ -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"

View 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
View 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;
}