mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-09 22:24:05 -08:00
feat: HTML node (#5107)
* ✨ Create HTML templating node PoC
* ♻️ Apply feedback
* 🐛 Scope CSS selectors
* ✏️ Adjust description
* ✏️ Adjust placeholder
* ⚡ Replace two custom files with package output
* ➕ Add `codemirror-lang-html-n8n`
* 👕 Appease linter
* 🧪 Skip event bus tests
* ⏪ Revert "Skip event bus tests"
This reverts commit 5702585d0d
.
* ✏️ Update codex
* 🧹 Cleanup
* 🐛 Restore original for `continueOnFail`
* ⚡ Improve `getResolvables`
This commit is contained in:
parent
a1710fbd27
commit
74e6f5d190
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
210
packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
Normal file
210
packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue
Normal file
|
@ -0,0 +1,210 @@
|
|||
<template>
|
||||
<div ref="htmlEditor" class="ph-no-capture"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import prettier from 'prettier/standalone';
|
||||
import htmlParser from 'prettier/parser-html';
|
||||
import cssParser from 'prettier/parser-postcss';
|
||||
import jsParser from 'prettier/parser-babel';
|
||||
import { html } from 'codemirror-lang-html-n8n';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { indentWithTab, insertNewlineAndIndent, history } from '@codemirror/commands';
|
||||
import { bracketMatching, ensureSyntaxTree, foldGutter, indentOnInput } from '@codemirror/language';
|
||||
import { EditorState, Extension } from '@codemirror/state';
|
||||
import {
|
||||
dropCursor,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expression.inputHandler';
|
||||
import { highlighter } from '@/plugins/codemirror/resolvableHighlighter';
|
||||
import { htmlEditorEventBus } from '@/event-bus/html-editor-event-bus';
|
||||
import { expressionManager } from '@/mixins/expressionManager';
|
||||
import { theme } from './theme';
|
||||
import { nonTakenRanges } from './utils';
|
||||
import type { Range, Section } from './types';
|
||||
|
||||
export default mixins(expressionManager).extend({
|
||||
name: 'HtmlEditor',
|
||||
props: {
|
||||
html: {
|
||||
type: String,
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editor: {} as EditorView,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
doc(): string {
|
||||
return this.editor.state.doc.toString();
|
||||
},
|
||||
|
||||
extensions(): Extension[] {
|
||||
return [
|
||||
bracketMatching(),
|
||||
autocompletion(),
|
||||
html({ autoCloseTags: true }),
|
||||
expressionInputHandler(),
|
||||
keymap.of([indentWithTab, { key: 'Enter', run: insertNewlineAndIndent }]),
|
||||
indentOnInput(),
|
||||
theme,
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
highlightActiveLine(),
|
||||
EditorState.readOnly.of(this.isReadOnly),
|
||||
EditorView.updateListener.of((viewUpdate: ViewUpdate) => {
|
||||
if (!viewUpdate.docChanged) return;
|
||||
|
||||
highlighter.removeColor(this.editor, this.htmlSegments);
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
|
||||
this.$emit('valueChanged', this.doc);
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
sections(): Section[] {
|
||||
const { state } = this.editor;
|
||||
|
||||
const fullTree = ensureSyntaxTree(this.editor.state, this.doc.length);
|
||||
|
||||
if (fullTree === null) {
|
||||
throw new Error(`Failed to parse syntax tree for: ${this.doc}`);
|
||||
}
|
||||
|
||||
let documentRange: Range = [-1, -1];
|
||||
const styleRanges: Range[] = [];
|
||||
const scriptRanges: Range[] = [];
|
||||
|
||||
fullTree.cursor().iterate((node) => {
|
||||
if (node.type.name === 'Document') {
|
||||
documentRange = [node.from, node.to];
|
||||
}
|
||||
|
||||
if (node.type.name === 'StyleSheet') {
|
||||
styleRanges.push([node.from - '<style>'.length, node.to + '</style>'.length]);
|
||||
}
|
||||
|
||||
if (node.type.name === 'Script') {
|
||||
scriptRanges.push([node.from - '<script>'.length, node.to + ('<' + '/script>').length]);
|
||||
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
|
||||
}
|
||||
});
|
||||
|
||||
const htmlRanges = nonTakenRanges(documentRange, [...styleRanges, ...scriptRanges]);
|
||||
|
||||
const styleSections: Section[] = styleRanges.map(([start, end]) => ({
|
||||
kind: 'style' as const,
|
||||
range: [start, end],
|
||||
content: state.sliceDoc(start, end).replace(/<\/?style>/g, ''),
|
||||
}));
|
||||
|
||||
const scriptSections: Section[] = scriptRanges.map(([start, end]) => ({
|
||||
kind: 'script' as const,
|
||||
range: [start, end],
|
||||
content: state.sliceDoc(start, end).replace(/<\/?script>/g, ''),
|
||||
}));
|
||||
|
||||
const htmlSections: Section[] = htmlRanges.map(([start, end]) => ({
|
||||
kind: 'html' as const,
|
||||
range: [start, end] as Range,
|
||||
content: state.sliceDoc(start, end).replace(/<\/html>/g, ''),
|
||||
// opening tag may contain attributes, e.g. <html lang="en">
|
||||
}));
|
||||
|
||||
return [...styleSections, ...scriptSections, ...htmlSections].sort(
|
||||
(a, b) => a.range[0] - b.range[0],
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
root() {
|
||||
const root = this.$refs.htmlEditor as HTMLDivElement | undefined;
|
||||
|
||||
if (!root) throw new Error('Expected div with ref "htmlEditor"');
|
||||
|
||||
return root;
|
||||
},
|
||||
|
||||
format() {
|
||||
const formatted = [];
|
||||
|
||||
for (const { kind, content } of this.sections) {
|
||||
if (kind === 'style') {
|
||||
const formattedStyle = prettier.format(content, {
|
||||
parser: 'css',
|
||||
plugins: [cssParser],
|
||||
});
|
||||
|
||||
formatted.push(`<style>\n${formattedStyle}</style>`);
|
||||
}
|
||||
|
||||
if (kind === 'script') {
|
||||
const formattedScript = prettier.format(content, {
|
||||
parser: 'babel',
|
||||
plugins: [jsParser],
|
||||
});
|
||||
|
||||
formatted.push(`<script>\n${formattedScript}<` + '/script>');
|
||||
// typing the closing script tag in full causes ESLint, Prettier and Vite to crash
|
||||
}
|
||||
|
||||
if (kind === 'html') {
|
||||
const match = content.match(/(?<pre>[\s\S]*<html[\s\S]*?>)(?<rest>[\s\S]*)/);
|
||||
|
||||
if (!match?.groups?.pre || !match.groups?.rest) continue;
|
||||
|
||||
// Prettier cannot format pre-HTML section, e.g. <!DOCTYPE html>, so keep as is
|
||||
|
||||
const { pre, rest } = match.groups;
|
||||
|
||||
const formattedRest = prettier.format(rest, {
|
||||
parser: 'html',
|
||||
plugins: [htmlParser],
|
||||
});
|
||||
|
||||
formatted.push(`${pre}\n${formattedRest}</html>`);
|
||||
}
|
||||
}
|
||||
|
||||
this.editor.dispatch({
|
||||
changes: { from: 0, to: this.doc.length, insert: formatted.join('\n\n') },
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
htmlEditorEventBus.$on('format-html', this.format);
|
||||
|
||||
const state = EditorState.create({ doc: this.html, extensions: this.extensions });
|
||||
|
||||
this.editor = new EditorView({ parent: this.root(), state });
|
||||
|
||||
highlighter.addColor(this.editor, this.resolvableSegments);
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
htmlEditorEventBus.$off('format-html', this.format);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module></style>
|
85
packages/editor-ui/src/components/HtmlEditor/theme.ts
Normal file
85
packages/editor-ui/src/components/HtmlEditor/theme.ts
Normal file
|
@ -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' },
|
||||
]),
|
||||
),
|
||||
];
|
7
packages/editor-ui/src/components/HtmlEditor/types.ts
Normal file
7
packages/editor-ui/src/components/HtmlEditor/types.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export type Range = [number, number];
|
||||
|
||||
export type Section = {
|
||||
kind: 'html' | 'script' | 'style';
|
||||
content: string;
|
||||
range: Range;
|
||||
};
|
40
packages/editor-ui/src/components/HtmlEditor/utils.ts
Normal file
40
packages/editor-ui/src/components/HtmlEditor/utils.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -79,6 +79,13 @@
|
|||
@valueChanged="valueChangedDebounced"
|
||||
/>
|
||||
|
||||
<html-editor
|
||||
v-else-if="getArgument('editor') === 'htmlEditor' && isHtmlNode(node)"
|
||||
:html="node.parameters.html"
|
||||
:isReadOnly="isReadOnly"
|
||||
@valueChanged="valueChangedDebounced"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="isEditor === true"
|
||||
class="code-edit clickable ph-no-capture"
|
||||
|
@ -337,6 +344,7 @@ import ExpressionParameterInput from '@/components/ExpressionParameterInput.vue'
|
|||
import PrismEditor from 'vue-prism-editor';
|
||||
import TextEdit from '@/components/TextEdit.vue';
|
||||
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
|
||||
import HtmlEditor from '@/components/HtmlEditor/HtmlEditor.vue';
|
||||
import { externalHooks } from '@/mixins/externalHooks';
|
||||
import { nodeHelpers } from '@/mixins/nodeHelpers';
|
||||
import { showMessage } from '@/mixins/showMessage';
|
||||
|
@ -345,7 +353,7 @@ import { hasExpressionMapping, isValueExpression, isResourceLocatorValue } from
|
|||
|
||||
import mixins from 'vue-typed-mixins';
|
||||
import { CUSTOM_API_CALL_KEY } from '@/constants';
|
||||
import { CODE_NODE_TYPE } from '@/constants';
|
||||
import { CODE_NODE_TYPE, HTML_NODE_TYPE } from '@/constants';
|
||||
import { PropType } from 'vue';
|
||||
import { debounceHelper } from '@/mixins/debounce';
|
||||
import { mapStores } from 'pinia';
|
||||
|
@ -353,6 +361,7 @@ import { useWorkflowsStore } from '@/stores/workflows';
|
|||
import { useNDVStore } from '@/stores/ndv';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes';
|
||||
import { useCredentialsStore } from '@/stores/credentials';
|
||||
import { htmlEditorEventBus } from '@/event-bus/html-editor-event-bus';
|
||||
|
||||
export default mixins(
|
||||
externalHooks,
|
||||
|
@ -365,6 +374,7 @@ export default mixins(
|
|||
components: {
|
||||
CodeEdit,
|
||||
CodeNodeEditor,
|
||||
HtmlEditor,
|
||||
ExpressionEdit,
|
||||
ExpressionParameterInput,
|
||||
NodeCredentials,
|
||||
|
@ -948,6 +958,9 @@ export default mixins(
|
|||
isCodeNode(node: INodeUi): boolean {
|
||||
return node.type === CODE_NODE_TYPE;
|
||||
},
|
||||
isHtmlNode(node: INodeUi): boolean {
|
||||
return node.type === HTML_NODE_TYPE;
|
||||
},
|
||||
rgbaToHex(value: string): string | null {
|
||||
// Convert rgba to hex from: https://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb
|
||||
const valueMatch = (value as string).match(
|
||||
|
@ -1077,6 +1090,8 @@ export default mixins(
|
|||
}
|
||||
}
|
||||
this.loadRemoteParameterOptions();
|
||||
} else if (command === 'formatHtml') {
|
||||
htmlEditorEventBus.$emit('format-html');
|
||||
}
|
||||
|
||||
if (this.node && (command === 'addExpression' || command === 'removeExpression')) {
|
||||
|
|
|
@ -28,6 +28,9 @@
|
|||
import { NodeParameterValueType } from 'n8n-workflow';
|
||||
import Vue, { PropType } from 'vue';
|
||||
import { isValueExpression, isResourceLocatorValue } from '@/utils';
|
||||
import { useNDVStore } from '@/stores/ndv';
|
||||
import { mapStores } from 'pinia';
|
||||
import { HTML_NODE_TYPE } from '@/constants';
|
||||
|
||||
export default Vue.extend({
|
||||
name: 'parameter-options',
|
||||
|
@ -51,6 +54,7 @@ export default Vue.extend({
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore),
|
||||
isDefault(): boolean {
|
||||
return this.parameter.default === this.value;
|
||||
},
|
||||
|
@ -91,6 +95,18 @@ export default Vue.extend({
|
|||
return !!this.getArgument('loadOptionsMethod') || !!this.getArgument('loadOptions');
|
||||
},
|
||||
actions(): Array<{ label: string; value: string; disabled?: boolean }> {
|
||||
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'),
|
||||
|
|
|
@ -316,6 +316,11 @@
|
|||
:totalRuns="maxRunIndex"
|
||||
/>
|
||||
|
||||
<run-data-html
|
||||
v-else-if="hasNodeRun && isPaneTypeOutput && displayMode === 'html'"
|
||||
:inputData="inputData"
|
||||
/>
|
||||
|
||||
<run-data-schema
|
||||
v-else-if="hasNodeRun && displayMode === 'schema'"
|
||||
:data="jsonData"
|
||||
|
@ -475,6 +480,7 @@ import {
|
|||
MAX_DISPLAY_DATA_SIZE,
|
||||
MAX_DISPLAY_ITEMS_AUTO_ALL,
|
||||
TEST_PIN_DATA,
|
||||
HTML_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
|
||||
import BinaryDataDisplay from '@/components/BinaryDataDisplay.vue';
|
||||
|
@ -497,6 +503,7 @@ import { useNodeTypesStore } from '@/stores/nodeTypes';
|
|||
const RunDataTable = () => 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) {
|
||||
|
|
50
packages/editor-ui/src/components/RunDataHtml.vue
Normal file
50
packages/editor-ui/src/components/RunDataHtml.vue
Normal file
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<div class="__html-display" v-html="html"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import type { INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
export default {
|
||||
name: 'RunDataHtml',
|
||||
props: {
|
||||
inputData: {
|
||||
type: Array as PropType<INodeExecutionData[]>,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
html() {
|
||||
if (!this.inputData) return '';
|
||||
|
||||
return this.scopeCss(this.inputData[0].json.html as string);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Scope all CSS selectors to prevent user stylesheets from leaking.
|
||||
*/
|
||||
scopeCss(str: string) {
|
||||
const stylesheets = str.match(/<style>([\s\S]*?)<\/style>/g);
|
||||
|
||||
if (!stylesheets) return str;
|
||||
|
||||
const map = stylesheets.reduce<Record<string, string>>((acc, match) => {
|
||||
match.split('\n').forEach((line) => {
|
||||
if (line.endsWith('{')) acc[line] = ['.__html-display', line].join(' ');
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.entries(map).reduce((acc, [key, value]) => acc.replace(key, value), str);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.__html-display {
|
||||
padding: 0 var(--spacing-s);
|
||||
}
|
||||
</style>
|
|
@ -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';
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export const htmlEditorEventBus = new Vue();
|
|
@ -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 [];
|
||||
|
||||
|
|
|
@ -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: <div></div> -> <div>{| }</div>
|
||||
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).
|
||||
*/
|
||||
|
|
|
@ -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;
|
||||
|
|
17
packages/nodes-base/nodes/Html/Html.node.json
Normal file
17
packages/nodes-base/nodes/Html/Html.node.json
Normal file
|
@ -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"]
|
||||
}
|
376
packages/nodes-base/nodes/Html/Html.node.ts
Normal file
376
packages/nodes-base/nodes/Html/Html.node.ts
Normal file
|
@ -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:
|
||||
'<b>Tips</b>: Type ctrl+space for completions. Use <code>{{ }}</code> for expressions and <code><style></code> tags for CSS. JS in <code><script></code> 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<INodeExecutionData[][]> {
|
||||
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<string | undefined>).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);
|
||||
}
|
||||
}
|
1
packages/nodes-base/nodes/Html/html.svg
Normal file
1
packages/nodes-base/nodes/Html/html.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 63.75 85" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round"><use xlink:href="#A" x="1.875" y="2.5"/><symbol id="A" overflow="visible"><g stroke="none"><path d="M60 15H0l5 57.5L30 80l25-7.5L60 15" fill="#e44d26"/><path d="M7 0v11h3V7h4v4h3V0h-3v4h-4V0zm24 0v11h3V5l2.5 4L39 5v6h3V0h-3l-2.5 3.5L34 0zm13 0v11h9V8h-6V0z" fill="#000"/><path d="M22.366 65.21l-8.214-2.464-.67-7.701-.221-2.545h8l.436 5.009L30 60v7.5zM30 32.5H19.522l.652 7.5H30v7.5h-9.174-8l-.652-7.5-.652-7.5-.652-7.5h8H30z" fill="#ebebeb"/><path d="M51.739 52.5l.435-5 .652-7.5.652-7.5.652-7.5.435-5H30v55l15.179-4.554 5.134-1.54.67-7.701.67-7.701z" fill="#f16529"/><path d="M19 0v3h3.5v8h3V3H29V0z" fill="#000"/><path d="M30 32.5h10.478 8l.652-7.5h-8H30zm9.174 15H30V40h9.826 8l-.652 7.5-.435 5-.221 2.545-.67 7.701-8.214 2.464L30 67.5V60l8.304-2.491.435-5.009z"/></g></symbol></svg>
|
After Width: | Height: | Size: 987 B |
44
packages/nodes-base/nodes/Html/placeholder.ts
Normal file
44
packages/nodes-base/nodes/Html/placeholder.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
export const placeholder = `
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>My HTML document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>This is an H1 heading</h1>
|
||||
<h2>This is an H2 heading</h2>
|
||||
<p>This is a paragraph</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #ff6d5a;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #909399;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
console.log("Hello World!");
|
||||
</script>
|
||||
`.trim();
|
11
packages/nodes-base/nodes/Html/types.ts
Normal file
11
packages/nodes-base/nodes/Html/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import type cheerio from 'cheerio';
|
||||
|
||||
export type Cheerio = ReturnType<typeof cheerio>;
|
||||
|
||||
export interface IValueData {
|
||||
attribute?: string;
|
||||
cssSelector: string;
|
||||
returnValue: string;
|
||||
key: string;
|
||||
returnArray: boolean;
|
||||
}
|
46
packages/nodes-base/nodes/Html/utils.ts
Normal file
46
packages/nodes-base/nodes/Html/utils.ts
Normal file
|
@ -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();
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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?: {
|
||||
|
|
|
@ -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'}
|
||||
|
|
Loading…
Reference in a new issue