diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json
index 39a3d935d0a07..dffe16ffb4837 100644
--- a/packages/editor-ui/package.json
+++ b/packages/editor-ui/package.json
@@ -40,6 +40,7 @@
"@fortawesome/free-solid-svg-icons": "^5.15.3",
"@fortawesome/vue-fontawesome": "^2.0.2",
"axios": "^0.21.1",
+ "codemirror-lang-html-n8n": "^1.0.0",
"codemirror-lang-n8n-expression": "^0.1.0",
"dateformat": "^3.0.3",
"esprima-next": "5.8.4",
@@ -62,6 +63,7 @@
"n8n-workflow": "~0.133.2",
"normalize-wheel": "^1.0.1",
"pinia": "^2.0.22",
+ "prettier": "^2.8.2",
"prismjs": "^1.17.1",
"timeago.js": "^4.0.2",
"uuid": "^8.3.2",
diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts
index 0dea128204359..66d207ee4244e 100644
--- a/packages/editor-ui/src/Interface.ts
+++ b/packages/editor-ui/src/Interface.ts
@@ -1102,7 +1102,7 @@ export interface IModalState {
httpNodeParameters?: string;
}
-export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema';
+export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema' | 'html';
export type NodePanelType = 'input' | 'output';
export interface TargetItem {
diff --git a/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue b/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
new file mode 100644
index 0000000000000..9ebb7749134a6
--- /dev/null
+++ b/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/HtmlEditor/theme.ts b/packages/editor-ui/src/components/HtmlEditor/theme.ts
new file mode 100644
index 0000000000000..f0460af501428
--- /dev/null
+++ b/packages/editor-ui/src/components/HtmlEditor/theme.ts
@@ -0,0 +1,85 @@
+import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
+import { EditorView } from '@codemirror/view';
+import { tags } from '@lezer/highlight';
+
+export const theme = [
+ EditorView.theme({
+ '&': {
+ 'font-size': '0.8em',
+ border: 'var(--border-base)',
+ borderRadius: 'var(--border-radius-base)',
+ backgroundColor: 'var(--color-code-background)',
+ color: 'var(--color-code-foreground)',
+ },
+ '.cm-content': {
+ fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important",
+ caretColor: 'var(--color-code-caret)',
+ },
+ '.cm-cursor, .cm-dropCursor': {
+ borderLeftColor: 'var(--color-code-caret)',
+ },
+ '&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': {
+ backgroundColor: 'var(--color-code-selection)',
+ },
+ '.cm-activeLine': {
+ backgroundColor: 'var(--color-code-lineHighlight)',
+ },
+ '.cm-activeLineGutter': {
+ backgroundColor: 'var(--color-code-lineHighlight)',
+ },
+ '.cm-gutters': {
+ backgroundColor: 'var(--color-code-gutterBackground)',
+ color: 'var(--color-code-gutterForeground)',
+ },
+ '.cm-scroller': {
+ overflow: 'auto',
+ maxHeight: '350px',
+ },
+ }),
+ syntaxHighlighting(
+ HighlightStyle.define([
+ { tag: tags.keyword, color: '#c678dd' },
+ {
+ tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
+ color: '#e06c75',
+ },
+ { tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' },
+ { tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#d19a66' },
+ { tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' },
+ {
+ tag: [
+ tags.typeName,
+ tags.className,
+ tags.number,
+ tags.changed,
+ tags.annotation,
+ tags.modifier,
+ tags.self,
+ tags.namespace,
+ ],
+ color: '#e06c75',
+ },
+ {
+ tag: [
+ tags.operator,
+ tags.operatorKeyword,
+ tags.url,
+ tags.escape,
+ tags.regexp,
+ tags.link,
+ tags.special(tags.string),
+ ],
+ color: '#56b6c2',
+ },
+ { tag: [tags.meta, tags.comment], color: '#7d8799' },
+ { tag: tags.strong, fontWeight: 'bold' },
+ { tag: tags.emphasis, fontStyle: 'italic' },
+ { tag: tags.strikethrough, textDecoration: 'line-through' },
+ { tag: tags.link, color: '#7d8799', textDecoration: 'underline' },
+ { tag: tags.heading, fontWeight: 'bold', color: '#e06c75' },
+ { tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' },
+ { tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' },
+ { tag: tags.invalid, color: 'red', 'font-weight': 'bold' },
+ ]),
+ ),
+];
diff --git a/packages/editor-ui/src/components/HtmlEditor/types.ts b/packages/editor-ui/src/components/HtmlEditor/types.ts
new file mode 100644
index 0000000000000..0c6db2f950391
--- /dev/null
+++ b/packages/editor-ui/src/components/HtmlEditor/types.ts
@@ -0,0 +1,7 @@
+export type Range = [number, number];
+
+export type Section = {
+ kind: 'html' | 'script' | 'style';
+ content: string;
+ range: Range;
+};
diff --git a/packages/editor-ui/src/components/HtmlEditor/utils.ts b/packages/editor-ui/src/components/HtmlEditor/utils.ts
new file mode 100644
index 0000000000000..239be2b831c1e
--- /dev/null
+++ b/packages/editor-ui/src/components/HtmlEditor/utils.ts
@@ -0,0 +1,40 @@
+import type { Range } from './types';
+
+/**
+ * Return the ranges of a full range that are _not_ within the taken ranges,
+ * assuming sorted taken ranges. e.g. `[0, 10]` and `[[2, 3], [7, 8]]`
+ * return `[[0, 1], [4, 6], [9, 10]]`
+ */
+export function nonTakenRanges(fullRange: Range, takenRanges: Range[]) {
+ const found = [];
+
+ const [fullStart, fullEnd] = fullRange;
+ let i = fullStart;
+ let curStart = fullStart;
+
+ takenRanges = [...takenRanges];
+
+ while (i < fullEnd) {
+ if (takenRanges.length === 0) {
+ found.push([curStart, fullEnd]);
+ break;
+ }
+
+ const [takenStart, takenEnd] = takenRanges[0];
+
+ if (i < takenStart) {
+ i++;
+ continue;
+ }
+
+ if (takenStart !== fullStart) {
+ found.push([curStart, i - 1]);
+ }
+
+ i = takenEnd + 1;
+ curStart = takenEnd + 1;
+ takenRanges.shift();
+ }
+
+ return found;
+}
diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue
index 0f1825c12e12d..f46debb344ce9 100644
--- a/packages/editor-ui/src/components/ParameterInput.vue
+++ b/packages/editor-ui/src/components/ParameterInput.vue
@@ -79,6 +79,13 @@
@valueChanged="valueChangedDebounced"
/>
+
+
{
+ if (
+ this.ndvStore.activeNode?.type === HTML_NODE_TYPE &&
+ this.ndvStore.activeNode?.parameters.operation === 'generateHtmlTemplate'
+ ) {
+ return [
+ {
+ label: 'Format HTML',
+ value: 'formatHtml',
+ },
+ ];
+ }
+
const actions = [
{
label: this.$locale.baseText('parameterInput.resetValue'),
diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue
index 7ef714f3d04e9..a2513e3de3468 100644
--- a/packages/editor-ui/src/components/RunData.vue
+++ b/packages/editor-ui/src/components/RunData.vue
@@ -316,6 +316,11 @@
:totalRuns="maxRunIndex"
/>
+
+
import('@/components/RunDataTable.vue');
const RunDataJson = () => import('@/components/RunDataJson.vue');
const RunDataSchema = () => import('@/components/RunDataSchema.vue');
+const RunDataHtml = () => import('@/components/RunDataHtml.vue');
export type EnterEditModeArgs = {
origin: 'editIconButton' | 'insertTestDataLink';
@@ -512,6 +519,7 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten
RunDataTable,
RunDataJson,
RunDataSchema,
+ RunDataHtml,
},
props: {
nodeUi: {
@@ -598,6 +606,8 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten
pane: this.paneType as 'input' | 'output',
branchIndex: this.currentOutputIndex,
});
+
+ if (this.paneType === 'output') this.setDisplayMode();
},
destroyed() {
this.hidePinDataDiscoveryTooltip();
@@ -651,6 +661,14 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten
defaults.unshift({ label: this.$locale.baseText('runData.schema'), value: 'schema' });
}
+ if (
+ this.isPaneTypeOutput &&
+ this.activeNode?.type === HTML_NODE_TYPE &&
+ this.activeNode.parameters.operation === 'generateHtmlTemplate'
+ ) {
+ defaults.unshift({ label: 'HTML', value: 'html' });
+ }
+
return defaults;
},
hasNodeRun(): boolean {
@@ -833,6 +851,9 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten
isPaneTypeInput(): boolean {
return this.paneType === 'input';
},
+ isPaneTypeOutput(): boolean {
+ return this.paneType === 'output';
+ },
},
methods: {
onItemHover(itemIndex: number | null) {
@@ -1275,11 +1296,26 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten
this.ndvStore.activeNodeName = this.node.name;
}
},
+ setDisplayMode() {
+ if (!this.activeNode) return;
+
+ const shouldDisplayHtml =
+ this.activeNode.type === HTML_NODE_TYPE &&
+ this.activeNode.parameters.operation === 'generateHtmlTemplate';
+
+ this.ndvStore.setPanelDisplayMode({
+ pane: 'output',
+ mode: shouldDisplayHtml ? 'html' : 'table',
+ });
+ },
},
watch: {
node() {
this.init();
},
+ hasNodeRun() {
+ if (this.paneType === 'output') this.setDisplayMode();
+ },
inputData: {
handler(data: INodeExecutionData[]) {
if (this.paneType && data) {
diff --git a/packages/editor-ui/src/components/RunDataHtml.vue b/packages/editor-ui/src/components/RunDataHtml.vue
new file mode 100644
index 0000000000000..53aff0c9eb883
--- /dev/null
+++ b/packages/editor-ui/src/components/RunDataHtml.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts
index 7e6924654cf31..cd105b1f6e66d 100644
--- a/packages/editor-ui/src/constants.ts
+++ b/packages/editor-ui/src/constants.ts
@@ -92,6 +92,7 @@ export const ELASTIC_SECURITY_NODE_TYPE = 'n8n-nodes-base.elasticSecurity';
export const EMAIL_SEND_NODE_TYPE = 'n8n-nodes-base.emailSend';
export const EMAIL_IMAP_NODE_TYPE = 'n8n-nodes-base.emailReadImap';
export const EXECUTE_COMMAND_NODE_TYPE = 'n8n-nodes-base.executeCommand';
+export const HTML_NODE_TYPE = 'n8n-nodes-base.html';
export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest';
export const HUBSPOT_TRIGGER_NODE_TYPE = 'n8n-nodes-base.hubspotTrigger';
export const IF_NODE_TYPE = 'n8n-nodes-base.if';
diff --git a/packages/editor-ui/src/event-bus/html-editor-event-bus.ts b/packages/editor-ui/src/event-bus/html-editor-event-bus.ts
new file mode 100644
index 0000000000000..0476b2bf2cd67
--- /dev/null
+++ b/packages/editor-ui/src/event-bus/html-editor-event-bus.ts
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export const htmlEditorEventBus = new Vue();
diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts
index a199c309197ed..09632813cd3b7 100644
--- a/packages/editor-ui/src/mixins/expressionManager.ts
+++ b/packages/editor-ui/src/mixins/expressionManager.ts
@@ -1,6 +1,6 @@
import mixins from 'vue-typed-mixins';
import { mapStores } from 'pinia';
-import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
+import { ensureSyntaxTree } from '@codemirror/language';
import { workflowHelpers } from '@/mixins/workflowHelpers';
import { useNDVStore } from '@/stores/ndv';
@@ -9,7 +9,7 @@ import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants';
import type { PropType } from 'vue';
import type { EditorView } from '@codemirror/view';
import type { TargetItem } from '@/Interface';
-import type { Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
+import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions';
export const expressionManager = mixins(workflowHelpers).extend({
props: {
@@ -56,6 +56,10 @@ export const expressionManager = mixins(workflowHelpers).extend({
return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext');
},
+ htmlSegments(): Html[] {
+ return this.segments.filter((s): s is Html => s.kind !== 'resolvable');
+ },
+
segments(): Segment[] {
if (!this.editor) return [];
diff --git a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts
index cb3f77b5ffdb6..2020812e7a1e7 100644
--- a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts
+++ b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts
@@ -29,7 +29,19 @@ const handler = EditorView.inputHandler.of((view, from, to, insert) => {
const transaction = insertBracket(view.state, insert);
- if (!transaction) return false;
+ if (!transaction) {
+ // customization: brace setup when surrounded by HTML tags: -> {| }
+ if (insert === '{') {
+ const cursor = view.state.selection.main.head;
+ view.dispatch({
+ changes: { from: cursor, insert: '{ }' },
+ selection: { anchor: cursor + 1 },
+ });
+ return true;
+ }
+
+ return false;
+ }
view.dispatch(transaction);
@@ -90,6 +102,7 @@ const [_, bracketState] = closeBrackets() as readonly Extension[];
* - prevent token autoclosing during autocompletion (exception: `{`),
* - prevent square bracket autoclosing prior to `.json`
* - inject whitespace and braces for resolvables
+ * - set up braces when surrounded by HTML tags
*
* Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79).
*/
diff --git a/packages/editor-ui/src/types/expressions.ts b/packages/editor-ui/src/types/expressions.ts
index 00efc61e34b4e..39a0ee771c776 100644
--- a/packages/editor-ui/src/types/expressions.ts
+++ b/packages/editor-ui/src/types/expressions.ts
@@ -6,6 +6,8 @@ export type Segment = Plaintext | Resolvable;
export type Plaintext = { kind: 'plaintext'; plaintext: string } & Range;
+export type Html = Plaintext; // for n8n parser, functionally identical to plaintext
+
export type Resolvable = {
kind: 'resolvable';
resolvable: string;
diff --git a/packages/nodes-base/nodes/Html/Html.node.json b/packages/nodes-base/nodes/Html/Html.node.json
new file mode 100644
index 0000000000000..b23e6c8cf9a24
--- /dev/null
+++ b/packages/nodes-base/nodes/Html/Html.node.json
@@ -0,0 +1,17 @@
+{
+ "node": "n8n-nodes-base.html",
+ "nodeVersion": "1.0",
+ "codexVersion": "1.0",
+ "categories": ["Core Nodes"],
+ "resources": {
+ "primaryDocumentation": [
+ {
+ "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.html/"
+ }
+ ]
+ },
+ "subcategories": {
+ "Core Nodes": ["Helpers", "Data Transformation"]
+ },
+ "alias": ["extract", "template"]
+}
diff --git a/packages/nodes-base/nodes/Html/Html.node.ts b/packages/nodes-base/nodes/Html/Html.node.ts
new file mode 100644
index 0000000000000..81f2c82c97fb0
--- /dev/null
+++ b/packages/nodes-base/nodes/Html/Html.node.ts
@@ -0,0 +1,376 @@
+import cheerio from 'cheerio';
+import {
+ INodeExecutionData,
+ IExecuteFunctions,
+ INodeType,
+ INodeTypeDescription,
+ IDataObject,
+ NodeOperationError,
+} from 'n8n-workflow';
+import { placeholder } from './placeholder';
+import { getResolvables, getValue } from './utils';
+import type { IValueData } from './types';
+
+export class Html implements INodeType {
+ description: INodeTypeDescription = {
+ displayName: 'HTML',
+ name: 'html',
+ icon: 'file:html.svg',
+ group: ['transform'],
+ version: 1,
+ subtitle: '={{ $parameter["operation"] }}',
+ description: 'Work with HTML',
+ defaults: {
+ name: 'HTML',
+ },
+ inputs: ['main'],
+ outputs: ['main'],
+ parameterPane: 'wide',
+ properties: [
+ {
+ displayName: 'Operation',
+ name: 'operation',
+ type: 'options',
+ noDataExpression: true,
+ options: [
+ {
+ name: 'Generate HTML Template',
+ value: 'generateHtmlTemplate',
+ action: 'Generate HTML template',
+ },
+ {
+ name: 'Extract HTML Content',
+ value: 'extractHtmlContent',
+ action: 'Extract HTML Content',
+ },
+ ],
+ default: 'generateHtmlTemplate',
+ },
+ {
+ displayName: 'HTML Template',
+ name: 'html',
+ typeOptions: {
+ editor: 'htmlEditor',
+ },
+ type: 'string',
+ default: placeholder,
+ noDataExpression: true,
+ description: 'HTML template to render',
+ displayOptions: {
+ show: {
+ operation: ['generateHtmlTemplate'],
+ },
+ },
+ },
+ {
+ displayName:
+ 'Tips: Type ctrl+space for completions. Use {{ }}
for expressions and <style>
tags for CSS. JS in <script>
tags is included but not executed in n8n.',
+ name: 'notice',
+ type: 'notice',
+ default: '',
+ displayOptions: {
+ show: {
+ operation: ['generateHtmlTemplate'],
+ },
+ },
+ },
+ {
+ displayName: 'Source Data',
+ name: 'sourceData',
+ type: 'options',
+ options: [
+ {
+ name: 'Binary',
+ value: 'binary',
+ },
+ {
+ name: 'JSON',
+ value: 'json',
+ },
+ ],
+ default: 'json',
+ description: 'If HTML should be read from binary or JSON data',
+ displayOptions: {
+ show: {
+ operation: ['extractHtmlContent'],
+ },
+ },
+ },
+ {
+ displayName: 'Binary Property',
+ name: 'dataPropertyName',
+ type: 'string',
+ displayOptions: {
+ show: {
+ operation: ['extractHtmlContent'],
+ sourceData: ['binary'],
+ },
+ },
+ default: 'data',
+ required: true,
+ description:
+ 'Name of the binary property in which the HTML to extract the data from can be found',
+ },
+ {
+ displayName: 'JSON Property',
+ name: 'dataPropertyName',
+ type: 'string',
+ displayOptions: {
+ show: {
+ operation: ['extractHtmlContent'],
+ sourceData: ['json'],
+ },
+ },
+ default: 'data',
+ required: true,
+ description:
+ 'Name of the JSON property in which the HTML to extract the data from can be found. The property can either contain a string or an array of strings.',
+ },
+ {
+ displayName: 'Extraction Values',
+ name: 'extractionValues',
+ placeholder: 'Add Value',
+ type: 'fixedCollection',
+ typeOptions: {
+ multipleValues: true,
+ },
+ displayOptions: {
+ show: {
+ operation: ['extractHtmlContent'],
+ },
+ },
+ default: {},
+ options: [
+ {
+ name: 'values',
+ displayName: 'Values',
+ values: [
+ {
+ displayName: 'Key',
+ name: 'key',
+ type: 'string',
+ default: '',
+ description: 'The key under which the extracted value should be saved',
+ },
+ {
+ displayName: 'CSS Selector',
+ name: 'cssSelector',
+ type: 'string',
+ default: '',
+ placeholder: '.price',
+ description: 'The CSS selector to use',
+ },
+ {
+ displayName: 'Return Value',
+ name: 'returnValue',
+ type: 'options',
+ options: [
+ {
+ name: 'Attribute',
+ value: 'attribute',
+ description: 'Get an attribute value like "class" from an element',
+ },
+ {
+ name: 'HTML',
+ value: 'html',
+ description: 'Get the HTML the element contains',
+ },
+ {
+ name: 'Text',
+ value: 'text',
+ description: 'Get only the text content of the element',
+ },
+ {
+ name: 'Value',
+ value: 'value',
+ description: 'Get value of an input, select or textarea',
+ },
+ ],
+ default: 'text',
+ description: 'What kind of data should be returned',
+ },
+ {
+ displayName: 'Attribute',
+ name: 'attribute',
+ type: 'string',
+ displayOptions: {
+ show: {
+ returnValue: ['attribute'],
+ },
+ },
+ default: '',
+ placeholder: 'class',
+ description: 'The name of the attribute to return the value off',
+ },
+ {
+ displayName: 'Return Array',
+ name: 'returnArray',
+ type: 'boolean',
+ default: false,
+ description:
+ 'Whether to return the values as an array so if multiple ones get found they also get returned separately. If not set all will be returned as a single string.',
+ },
+ ],
+ },
+ ],
+ },
+
+ {
+ displayName: 'Options',
+ name: 'options',
+ type: 'collection',
+ placeholder: 'Add Option',
+ default: {},
+ displayOptions: {
+ show: {
+ operation: ['extractHtmlContent'],
+ },
+ },
+ options: [
+ {
+ displayName: 'Trim Values',
+ name: 'trimValues',
+ type: 'boolean',
+ default: true,
+ description:
+ 'Whether to remove automatically all spaces and newlines from the beginning and end of the values',
+ },
+ ],
+ },
+ ],
+ };
+
+ async execute(this: IExecuteFunctions): Promise {
+ const items = this.getInputData();
+ let item: INodeExecutionData;
+ const returnData: INodeExecutionData[] = [];
+ const operation = this.getNodeParameter('operation', 0);
+
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
+ try {
+ if (operation === 'generateHtmlTemplate') {
+ // ----------------------------------
+ // generateHtmlTemplate
+ // ----------------------------------
+
+ let html = this.getNodeParameter('html', itemIndex) as string;
+
+ for (const resolvable of getResolvables(html)) {
+ html = html.replace(resolvable, this.evaluateExpression(resolvable, itemIndex) as any);
+ }
+
+ const result = this.helpers.constructExecutionMetaData(
+ this.helpers.returnJsonArray({ html }),
+ {
+ itemData: { item: itemIndex },
+ },
+ );
+
+ returnData.push(...result);
+ } else if (operation === 'extractHtmlContent') {
+ // ----------------------------------
+ // extractHtmlContent
+ // ----------------------------------
+
+ const dataPropertyName = this.getNodeParameter('dataPropertyName', itemIndex);
+ const extractionValues = this.getNodeParameter(
+ 'extractionValues',
+ itemIndex,
+ ) as IDataObject;
+ const options = this.getNodeParameter('options', itemIndex, {});
+ const sourceData = this.getNodeParameter('sourceData', itemIndex) as string;
+
+ item = items[itemIndex];
+
+ let htmlArray: string[] | string = [];
+ if (sourceData === 'json') {
+ if (item.json[dataPropertyName] === undefined) {
+ throw new NodeOperationError(
+ this.getNode(),
+ `No property named "${dataPropertyName}" exists!`,
+ { itemIndex },
+ );
+ }
+ htmlArray = item.json[dataPropertyName] as string;
+ } else {
+ if (item.binary === undefined) {
+ throw new NodeOperationError(
+ this.getNode(),
+ 'No item does not contain binary data!',
+ {
+ itemIndex,
+ },
+ );
+ }
+ if (item.binary[dataPropertyName] === undefined) {
+ throw new NodeOperationError(
+ this.getNode(),
+ `No property named "${dataPropertyName}" exists!`,
+ { itemIndex },
+ );
+ }
+
+ const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(
+ itemIndex,
+ dataPropertyName,
+ );
+ htmlArray = binaryDataBuffer.toString('utf-8');
+ }
+
+ // Convert it always to array that it works with a string or an array of strings
+ if (!Array.isArray(htmlArray)) {
+ htmlArray = [htmlArray];
+ }
+
+ for (const html of htmlArray as string[]) {
+ const $ = cheerio.load(html);
+
+ const newItem: INodeExecutionData = {
+ json: {},
+ pairedItem: {
+ item: itemIndex,
+ },
+ };
+
+ // Itterate over all the defined values which should be extracted
+ let htmlElement;
+ for (const valueData of extractionValues.values as IValueData[]) {
+ htmlElement = $(valueData.cssSelector);
+
+ if (valueData.returnArray) {
+ // An array should be returned so itterate over one
+ // value at a time
+ newItem.json[valueData.key] = [];
+ htmlElement.each((i, el) => {
+ (newItem.json[valueData.key] as Array).push(
+ getValue($(el), valueData, options),
+ );
+ });
+ } else {
+ // One single value should be returned
+ newItem.json[valueData.key] = getValue(htmlElement, valueData, options);
+ }
+ }
+ returnData.push(newItem);
+ }
+ }
+ } catch (error) {
+ if (this.continueOnFail()) {
+ returnData.push({
+ json: {
+ error: error.message,
+ },
+ pairedItem: {
+ item: itemIndex,
+ },
+ });
+ continue;
+ }
+
+ throw error;
+ }
+ }
+
+ return this.prepareOutputData(returnData);
+ }
+}
diff --git a/packages/nodes-base/nodes/Html/html.svg b/packages/nodes-base/nodes/Html/html.svg
new file mode 100644
index 0000000000000..80cab9e7ace63
--- /dev/null
+++ b/packages/nodes-base/nodes/Html/html.svg
@@ -0,0 +1 @@
+
diff --git a/packages/nodes-base/nodes/Html/placeholder.ts b/packages/nodes-base/nodes/Html/placeholder.ts
new file mode 100644
index 0000000000000..8d8a9faab45bc
--- /dev/null
+++ b/packages/nodes-base/nodes/Html/placeholder.ts
@@ -0,0 +1,44 @@
+export const placeholder = `
+
+
+
+
+
+ My HTML document
+
+
+
+
This is an H1 heading
+
This is an H2 heading
+
This is a paragraph
+
+
+
+
+
+
+
+`.trim();
diff --git a/packages/nodes-base/nodes/Html/types.ts b/packages/nodes-base/nodes/Html/types.ts
new file mode 100644
index 0000000000000..bbe5ab68ac375
--- /dev/null
+++ b/packages/nodes-base/nodes/Html/types.ts
@@ -0,0 +1,11 @@
+import type cheerio from 'cheerio';
+
+export type Cheerio = ReturnType;
+
+export interface IValueData {
+ attribute?: string;
+ cssSelector: string;
+ returnValue: string;
+ key: string;
+ returnArray: boolean;
+}
diff --git a/packages/nodes-base/nodes/Html/utils.ts b/packages/nodes-base/nodes/Html/utils.ts
new file mode 100644
index 0000000000000..3172908a872a4
--- /dev/null
+++ b/packages/nodes-base/nodes/Html/utils.ts
@@ -0,0 +1,46 @@
+import type { IDataObject } from 'n8n-workflow';
+import type { IValueData, Cheerio } from './types';
+
+/**
+ * @TECH_DEBT Explore replacing with handlebars
+ */
+export function getResolvables(html: string) {
+ if (!html) return [];
+
+ const resolvables = [];
+ const resolvableRegex = /({{[\s\S]*?}})/g;
+
+ let match;
+
+ while ((match = resolvableRegex.exec(html)) !== null) {
+ if (match[1]) {
+ resolvables.push(match[1]);
+ }
+ }
+
+ return resolvables;
+}
+
+// The extraction functions
+const extractFunctions: {
+ [key: string]: ($: Cheerio, valueData: IValueData) => string | undefined;
+} = {
+ attribute: ($: Cheerio, valueData: IValueData): string | undefined =>
+ $.attr(valueData.attribute!),
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ html: ($: Cheerio, _valueData: IValueData): string | undefined => $.html() || undefined,
+ text: ($: Cheerio, _valueData: IValueData): string | undefined => $.text(),
+ value: ($: Cheerio, _valueData: IValueData): string | undefined => $.val(),
+};
+
+/**
+ * Simple helper function which applies options
+ */
+export function getValue($: Cheerio, valueData: IValueData, options: IDataObject) {
+ const value = extractFunctions[valueData.returnValue]($, valueData);
+ if (options.trimValues === false || value === undefined) {
+ return value;
+ }
+
+ return value.trim();
+}
diff --git a/packages/nodes-base/nodes/HtmlExtract/HtmlExtract.node.ts b/packages/nodes-base/nodes/HtmlExtract/HtmlExtract.node.ts
index cf731ad498821..843f0c874a4ac 100644
--- a/packages/nodes-base/nodes/HtmlExtract/HtmlExtract.node.ts
+++ b/packages/nodes-base/nodes/HtmlExtract/HtmlExtract.node.ts
@@ -48,6 +48,7 @@ export class HtmlExtract implements INodeType {
icon: 'fa:cut',
group: ['transform'],
version: 1,
+ hidden: true,
subtitle: '={{$parameter["sourceData"] + ": " + $parameter["dataPropertyName"]}}',
description: 'Extracts data from HTML',
defaults: {
diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json
index a2429dbc13cdf..f5cb67fdfce91 100644
--- a/packages/nodes-base/package.json
+++ b/packages/nodes-base/package.json
@@ -501,6 +501,7 @@
"dist/nodes/HighLevel/HighLevel.node.js",
"dist/nodes/HomeAssistant/HomeAssistant.node.js",
"dist/nodes/HtmlExtract/HtmlExtract.node.js",
+ "dist/nodes/Html/Html.node.js",
"dist/nodes/HttpRequest/HttpRequest.node.js",
"dist/nodes/Hubspot/Hubspot.node.js",
"dist/nodes/Hubspot/HubspotTrigger.node.js",
diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts
index 5169359a64d3c..799872891343e 100644
--- a/packages/workflow/src/Interfaces.ts
+++ b/packages/workflow/src/Interfaces.ts
@@ -980,7 +980,7 @@ export type NodePropertyTypes =
export type CodeAutocompleteTypes = 'function' | 'functionItem';
-export type EditorTypes = 'code' | 'codeNodeEditor' | 'json';
+export type EditorTypes = 'code' | 'codeNodeEditor' | 'htmlEditor' | 'json';
export interface ILoadOptions {
routing?: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a9ae5ec2560c7..7e77d41d41dbb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -542,6 +542,7 @@ importers:
'@vitejs/plugin-vue2': ^1.1.2
axios: ^0.21.1
c8: ^7.12.0
+ codemirror-lang-html-n8n: ^1.0.0
codemirror-lang-n8n-expression: ^0.1.0
dateformat: ^3.0.3
esprima-next: 5.8.4
@@ -565,6 +566,7 @@ importers:
n8n-workflow: ~0.133.2
normalize-wheel: ^1.0.1
pinia: ^2.0.22
+ prettier: ^2.8.2
prismjs: ^1.17.1
sass: ^1.55.0
sass-loader: ^10.1.1
@@ -605,6 +607,7 @@ importers:
'@fortawesome/free-solid-svg-icons': 5.15.4
'@fortawesome/vue-fontawesome': 2.0.8_tc4irwwlc7tvswdic4b5cxexom
axios: 0.21.4
+ codemirror-lang-html-n8n: 1.0.0
codemirror-lang-n8n-expression: 0.1.0_zyklskjzaprvz25ee7sq7godcq
dateformat: 3.0.3
esprima-next: 5.8.4
@@ -627,6 +630,7 @@ importers:
n8n-workflow: link:../workflow
normalize-wheel: 1.0.1
pinia: 2.0.23_xjcbg5znturqejtkpd33hx726m
+ prettier: 2.8.2
prismjs: 1.29.0
timeago.js: 4.0.2
uuid: 8.3.2
@@ -2713,6 +2717,18 @@ packages:
'@lezer/common': 1.0.1
dev: false
+ /@codemirror/lang-css/6.0.1_gu445lycfriim3kznnyeahleva:
+ resolution: {integrity: sha512-rlLq1Dt0WJl+2epLQeAsfqIsx3lGu4HStHCJu95nGGuz2P2fNugbU3dQYafr2VRjM4eMC9HviI6jvS98CNtG5w==}
+ dependencies:
+ '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
+ '@codemirror/language': 6.2.1
+ '@codemirror/state': 6.1.4
+ '@lezer/css': 1.1.1
+ transitivePeerDependencies:
+ - '@codemirror/view'
+ - '@lezer/common'
+ dev: false
+
/@codemirror/lang-javascript/6.1.2:
resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==}
dependencies:
@@ -3315,12 +3331,27 @@ packages:
resolution: {integrity: sha512-8TR5++Q/F//tpDsLd5zkrvEX5xxeemafEaek7mUp7Y+bI8cKQXdSqhzTOBaOogETcMOVr0pT3BBPXp13477ciw==}
dev: false
+ /@lezer/css/1.1.1:
+ resolution: {integrity: sha512-mSjx+unLLapEqdOYDejnGBokB5+AiJKZVclmud0MKQOKx3DLJ5b5VTCstgDDknR6iIV4gVrN6euzsCnj0A2gQA==}
+ dependencies:
+ '@lezer/highlight': 1.1.1
+ '@lezer/lr': 1.2.3
+ dev: false
+
/@lezer/highlight/1.1.1:
resolution: {integrity: sha512-duv9D23O9ghEDnnUDmxu+L8pJy4nYo4AbCOHIudUhscrLSazqeJeK1V50EU6ZufWF1zv0KJwu/frFRyZWXxHBQ==}
dependencies:
'@lezer/common': 1.0.1
dev: false
+ /@lezer/html/1.3.0:
+ resolution: {integrity: sha512-jU/ah8DEoiECLTMouU/X/ujIg6k9WQMIOFMaCLebzaXfrguyGaR3DpTgmk0tbljiuIJ7hlmVJPcJcxGzmCd0Mg==}
+ dependencies:
+ '@lezer/common': 1.0.1
+ '@lezer/highlight': 1.1.1
+ '@lezer/lr': 1.2.3
+ dev: false
+
/@lezer/javascript/1.0.2:
resolution: {integrity: sha512-IjOVeIRhM8IuafWNnk+UzRz7p4/JSOKBNINLYLsdSGuJS9Ju7vFdc82AlTt0jgtV5D8eBZf4g0vK4d3ttBNz7A==}
dependencies:
@@ -8562,7 +8593,7 @@ packages:
resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==}
dependencies:
pascal-case: 3.1.2
- tslib: 2.4.0
+ tslib: 2.4.1
/camelcase-css/2.0.1:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
@@ -9085,6 +9116,22 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
+ /codemirror-lang-html-n8n/1.0.0:
+ resolution: {integrity: sha512-ofNP6VTDGJ5rue+kTCZlDZdF1PnE0sl2cAkfrsCAd5MlBgDmqTwuFJIkTI6KXOJXs0ucdTYH6QLhy9BSW7EaOQ==}
+ dependencies:
+ '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i
+ '@codemirror/lang-css': 6.0.1_gu445lycfriim3kznnyeahleva
+ '@codemirror/lang-javascript': 6.1.2
+ '@codemirror/language': 6.2.1
+ '@codemirror/state': 6.1.4
+ '@codemirror/view': 6.5.1
+ '@lezer/common': 1.0.1
+ '@lezer/css': 1.1.1
+ '@lezer/highlight': 1.1.1
+ '@lezer/html': 1.3.0
+ '@lezer/lr': 1.2.3
+ dev: false
+
/codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq:
resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==}
dependencies:
@@ -10464,7 +10511,7 @@ packages:
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
dependencies:
no-case: 3.0.4
- tslib: 2.4.0
+ tslib: 2.4.1
/dotenv-expand/5.1.0:
resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==}
@@ -15401,7 +15448,7 @@ packages:
/lower-case/2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
dependencies:
- tslib: 2.4.0
+ tslib: 2.4.1
/lru-cache/4.0.2:
resolution: {integrity: sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==}
@@ -16217,7 +16264,7 @@ packages:
resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==}
dependencies:
lower-case: 2.0.2
- tslib: 2.4.0
+ tslib: 2.4.1
/nock/13.2.9:
resolution: {integrity: sha512-1+XfJNYF1cjGB+TKMWi29eZ0b82QOvQs2YoLNzbpWGqFMtRQHTa57osqdGj4FrFPgkO4D4AZinzUJR9VvW3QUA==}
@@ -16933,7 +16980,7 @@ packages:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
dependencies:
dot-case: 3.0.4
- tslib: 2.4.0
+ tslib: 2.4.1
/parent-module/1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
@@ -17050,7 +17097,7 @@ packages:
resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==}
dependencies:
no-case: 3.0.4
- tslib: 2.4.0
+ tslib: 2.4.1
/pascalcase/0.1.1:
resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==}
@@ -17652,6 +17699,12 @@ packages:
hasBin: true
dev: true
+ /prettier/2.8.2:
+ resolution: {integrity: sha512-BtRV9BcncDyI2tsuS19zzhzoxD8Dh8LiCx7j7tHzrkz8GFXAexeWFdi22mjE1d16dftH2qNaytVxqiRTGlMfpw==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+ dev: false
+
/pretty-bytes/5.6.0:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'}
@@ -20813,6 +20866,9 @@ packages:
/tslib/2.4.0:
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
+ /tslib/2.4.1:
+ resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==}
+
/tsscmp/1.0.6:
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
engines: {node: '>=0.6.x'}