diff --git a/package.json b/package.json index c750667a4..6dddd5647 100644 --- a/package.json +++ b/package.json @@ -14,17 +14,22 @@ "@fullcalendar/timegrid": "^5.11.3", "@juggle/resize-observer": "^3.4.0", "@stomp/stompjs": "^6.1.2", + "dompurify": "^2.4.0", "google-code-prettify": "^1.0.5", + "highlight.js": "<11", "imagesloaded": "^4.1.4", "intersection-observer": "^0.12.2", "jquery": "^3.6.1", "jquery-jcrop": "^0.9.13", "luxon": "2.4.0", + "marked": "^4.1.0", "masonry-layout": "^4.2.2", "moment": "^2.29.4", "moment-timezone": "^0.5.37", "photoswipe": "^4.1.3", "pikaday": "^1.8.0", + "prettier": "^2.7.1", + "prismjs": "^1.29.0", "promise-polyfill": "^8.2.3", "screenfull": "^6.0.2", "select2": "^3.5.1", diff --git a/src/core/registry.js b/src/core/registry.js index 1f13c3b29..03fd0430c 100644 --- a/src/core/registry.js +++ b/src/core/registry.js @@ -140,6 +140,13 @@ const registry = { patterns.splice(patterns.indexOf("validation"), 1); patterns.unshift("validation"); } + // Add clone-code to the very beginning - we want to copy the markup + // before any other patterns changed the markup. + if (patterns.includes("clone-code")) { + patterns.splice(patterns.indexOf("clone-code"), 1); + patterns.unshift("clone-code"); + } + return patterns; }, @@ -150,6 +157,9 @@ const registry = { if (typeof content === "string") { content = document.querySelector(content); + } else if (content instanceof Text) { + // No need to scan a TextNode. + return; } else if (content.jquery) { content = content[0]; } diff --git a/src/pat/clone-code/clone-code.js b/src/pat/clone-code/clone-code.js new file mode 100644 index 000000000..f760147b9 --- /dev/null +++ b/src/pat/clone-code/clone-code.js @@ -0,0 +1,81 @@ +import { BasePattern } from "../../core/basepattern"; +import code_wrapper_template from "./templates/code-wrapper.html"; +import dom from "../../core/dom"; +import Parser from "../../core/parser"; +import registry from "../../core/registry"; +import utils from "../../core/utils"; + +export const parser = new Parser("clone-code"); +parser.addArgument("source", ":first-child"); +parser.addArgument("features", null, ["format"]); + +class Pattern extends BasePattern { + static name = "clone-code"; + static trigger = ".pat-clone-code"; + parser = parser; + + async init() { + // Source + if (this.options.source.lastIndexOf(":", 0) === 0) { + this.source = this.el.querySelector(this.options.source); + } else { + this.source = document.querySelector(this.options.source); + } + await this.clone(); + } + + async clone() { + // Clone the template. + let markup = + this.source.nodeName === "TEMPLATE" + ? this.source.innerHTML + : this.source.outerHTML; + + // Create temporary wrapper. + let tmp_wrapper; + if (this.source.nodeName === "HTML") { + // We have a full HTML document which we cannot wrap into a div. + tmp_wrapper = new DOMParser().parseFromString(markup, "text/html"); + } else { + tmp_wrapper = document.createElement("div"); + tmp_wrapper.innerHTML = markup; + } + + // Remove elements with the class ``clone-ignore``. + const ignore = tmp_wrapper.querySelectorAll(".clone-ignore"); + for (const _el of ignore) { + _el.remove(); + } + + // Get back the clone string depending of what the wrapper is. + markup = + tmp_wrapper instanceof HTMLDocument + ? tmp_wrapper.documentElement.outerHTML + : tmp_wrapper.innerHTML; + + if (this.options.features?.includes("format")) { + // Format the markup. + const prettier = (await import("prettier/standalone")).default; + const parser_html = (await import("prettier/parser-html")).default; + markup = prettier.format(markup, { + parser: "html", + plugins: [parser_html], + }); + } + + markup = utils.escape_html(markup); + const pre_code_markup = dom.template(code_wrapper_template, { markup: markup }); + + // Now we need to wrap the contents in any case in a div. + tmp_wrapper = document.createElement("div"); + tmp_wrapper.innerHTML = pre_code_markup; + const pre_code_el = tmp_wrapper.children[0]; + + this.el.appendChild(pre_code_el); + registry.scan(pre_code_el); + } +} + +registry.register(Pattern); +export default Pattern; +export { Pattern }; diff --git a/src/pat/clone-code/clone-code.test.js b/src/pat/clone-code/clone-code.test.js new file mode 100644 index 000000000..60ce3187a --- /dev/null +++ b/src/pat/clone-code/clone-code.test.js @@ -0,0 +1,107 @@ +import Pattern from "./clone-code"; +import utils from "@patternslib/patternslib/src/core/utils"; + +describe("pat-clone-code", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("is initialized correctly", async () => { + document.body.innerHTML = ` +
+

hello world

+
+ `; + const el = document.querySelector(".pat-clone-code"); + + new Pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + const _el = document.body.querySelector( + ".pat-clone-code pre code.language-html" + ); + expect(_el).toBeTruthy(); + expect(_el.innerHTML.trim()).toBe("<p>hello world</p>"); + expect(_el.textContent.trim()).toBe("

hello world

"); + }); + + it("clones another source", async () => { + document.body.innerHTML = ` +
+

hello world

+
+ `; + const el = document.querySelector(".pat-clone-code"); + + new Pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + console.log(document.body.innerHTML); + const _el = document.body.querySelector( + ".pat-clone-code pre code.language-html" + ); + expect(_el).toBeTruthy(); + + expect(_el.innerHTML.trim().indexOf("<html>")).toBe(0); + expect(_el.innerHTML.trim().indexOf("<body") > 0).toBe(true); + }); + + it("ignores .clone-ignore", async () => { + document.body.innerHTML = ` +
+
+
1
+
2
+
3
+
+
+ `; + const el = document.querySelector(".pat-clone-code"); + + new Pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + const _el = document.body.querySelector( + ".pat-clone-code pre code.language-html" + ); + expect(_el).toBeTruthy(); + console.log(document.body.innerHTML); + expect(_el.textContent.trim()).toBe(`
+
1
+ +
3
+
`); + }); + + it("pretty prints output", async () => { + document.body.innerHTML = ` +
+
+
1
+
2
+ +
3
+
+
+ `; + const el = document.querySelector(".pat-clone-code"); + + new Pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + const _el = document.body.querySelector( + ".pat-clone-code pre code.language-html" + ); + expect(_el).toBeTruthy(); + expect(_el.textContent.trim()).toBe(`
+
1
+
2
+ +
3
+
`); + }); +}); diff --git a/src/pat/clone-code/documentation.md b/src/pat/clone-code/documentation.md new file mode 100644 index 000000000..c31f2a74e --- /dev/null +++ b/src/pat/clone-code/documentation.md @@ -0,0 +1,112 @@ +## Description + +The clone pattern lets the website user clone elements in the page. + +## Documentation + +The clone pattern is typically used in case you want to create a form on which it is unknown how many instances the user will need of a certain field or group of fields. +For instance if you want to ask the user to fill out the name and birthdate of each family member. + +### Usage + +This pattern is enabled by adding the `pat-clone` class on a container element which contains the original element and any clones of it that may have beeen added. +The first element inside the .pat-clone container is by default assumed to be the original element may be cloned by the user. + +Consider the following markup: + +

List of family members

+ +
+ +
+ Family member 1 + +
+ +
+ + +
+ +Each time the user clicks on the button saying 'Add an extra family member', the +pattern will make a copy of the first element inside the +`.pat-clone` element, unless the `template` property is used to configure a +different clone template. The `template` property takes a CSS selector as +value. + +Typically when using a template element, such an element would be hidden from view. + +The new clone is always appended at the end, inside the `.pat-clone` element. + +When creating a `.pat-clone` element containing existing clones, it's +important that each existing clone either gets the `clone` CSS class or that you +pass in a unique CSS selector for each clone via the `clone-element` +property. This allows the pattern to properly determine how many existing +clones there are to start with. + +#### Incrementation of values in clones + +The pat-clone pattern will automatically add up any number in the values of name and value attributes. +For instance, if you have `name="item-1"` in your markup, then the first clone will be +`name="item-2"` and the one after that `name="item-3"` etc.. If you want to print a number +— for instance in a header of each clone — then you can use the syntax: `#{1}`. This string +will be replaced by an number that's also increased by 1 for each clone. + +### Example with a hidden template + +The markup below would have exactly the same effect as the first example, but using a hidden template. This might come in handy when the first instance shown should either contain different information, or if it will have pre-filled values by the server. + +

List of family members

+ +
+ +
+ Family member 1 + +
+ +
+ + + + + + +
+ +### Example with a hidden template which includes a pattern + +Patterns in templates are initialized after cloning. +However, the patterns in the template itself are not initialized if the template has the attribute ``hidden`` or the class ``disable-patterns``. +This is to prevent double-initialization within the template and after being cloned. + + + The text inside the element will be rendered using the [PageDown](http://code.google.com/p/pagedown/) renderer. The original diff --git a/src/pat/markdown/index.html b/src/pat/markdown/index.html index 4fc5cfbcb..a0a7f1348 100644 --- a/src/pat/markdown/index.html +++ b/src/pat/markdown/index.html @@ -1,23 +1,18 @@ - - Demo page - - - - - -

The content below is rendered from Markdown

-
-
- -
+  
+    Demo page
+    
+    
+    
+  
+  
+    

The content below is rendered from Markdown

+
+
+
# Header 1 ## Header 2 @@ -33,34 +28,38 @@

Autoloading content from a separate Markdown file

| Felis | dui | porttitor | |-------|-----------|-----------| | dui | porttitor | eros | -
-
- -

- Click here to inject more markdown content -

- -
- The content rendered from markdown will appear here. -
-
- +Some code: + +```javascript +const pattern = registry.patterns[name]; +if (pattern.transform) { + try { + pattern.transform($content); + } catch (e) { + if (dont_catch) { + throw(e); + } + log.error("Transform error for pattern" + name, e); + } +} +``` + +
+ +
    +
  1. Inject markdown content with data-type declaration.
  2. +
  3. Inject markdown content, automatically detected as markdown based on the file name extension.
  4. +
+
+ The content rendered from markdown will appear here. +
+ + diff --git a/src/pat/markdown/injected-page.html b/src/pat/markdown/injected-page.html deleted file mode 100644 index c743f20c6..000000000 --- a/src/pat/markdown/injected-page.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - Demo page - - - - - - This content will be replaced by the source content of the link, - rendered as HTML. - - diff --git a/src/pat/markdown/markdown.js b/src/pat/markdown/markdown.js index ba0b21aaf..e8127df73 100644 --- a/src/pat/markdown/markdown.js +++ b/src/pat/markdown/markdown.js @@ -2,12 +2,13 @@ import $ from "jquery"; import logging from "../../core/logging"; import utils from "../../core/utils"; import Base from "../../core/base"; +import events from "../../core/events"; import inject from "../inject/inject"; -var log = logging.getLogger("pat.markdown"); -var is_markdown_resource = /\.md$/; +const log = logging.getLogger("pat.markdown"); +const is_markdown_resource = /\.md$/; -var Markdown = Base.extend({ +const Markdown = Base.extend({ name: "markdown", trigger: ".pat-markdown", @@ -17,45 +18,38 @@ var Markdown = Base.extend({ * to pat-inject. The following only applies to standalone, when * $el is explicitly configured with the pat-markdown trigger. */ - var source = this.$el.is(":input") ? this.$el.val() : this.$el.text(); + const source = this.$el.is(":input") + ? this.$el.val() + : this.$el[0].innerHTML; let rendered = await this.render(source); - rendered.replaceAll(this.$el); + this.el.innerHTML = ""; + this.el.append(...rendered[0].childNodes); } }, async render(text) { - const Showdown = (await import("showdown")).default; + const marked = (await import("marked")).marked; + const DOMPurify = (await import("dompurify")).default; + const SyntaxHighlight = (await import("../syntax-highlight/syntax-highlight")).default; // prettier-ignore - // Add support for syntax highlighting via pat-syntax-highlight - Showdown.extensions.prettify = function () { - return [ - { - type: "output", - filter: function (source) { - return source.replace(/(
)?/gi, function (match, pre) {
-                            if (pre) {
-                                return '
';
-                            } else {
-                                return '';
-                            }
-                        });
-                    },
-                },
-            ];
-        };
-
-        const $rendering = $("
"); - const converter = new Showdown.Converter({ - tables: true, - extensions: ["prettify"], - }); - $rendering.html(converter.makeHtml(text)); - return $rendering; + const wrapper = document.createElement("div"); + const parsed = DOMPurify.sanitize(marked.parse(text)); + wrapper.innerHTML = parsed; + for (const item of wrapper.querySelectorAll("pre > code")) { + const pre = item.parentElement; + pre.classList.add("pat-syntax-highlight"); + // If the code block language was set in a fenced code block, + // marked has already set the language as a class on the code tag. + // pat-syntax-highlight will understand this. + new SyntaxHighlight(pre); + await events.await_event(pre, "init.syntax-highlight.patterns"); + } + return $(wrapper); }, async renderForInjection(cfg, data) { - var header, - source = data; + let header; + let source = data; if (cfg.source && (header = /^#+\s*(.*)/.exec(cfg.source)) !== null) { source = this.extractSection(source, header[1]); if (source === null) { @@ -69,21 +63,18 @@ var Markdown = Base.extend({ }, extractSection(text, header) { - var pattern, level; + let pattern; header = utils.escapeRegExp(header); - var matcher = new RegExp( - "^((#+)\\s*@TEXT@\\s*|@TEXT@\\s*\\n([=-])+\\s*)$".replace( - /@TEXT@/g, - header - ), - "m" - ), - match = matcher.exec(text); + let matcher = new RegExp( + "^((#+)\\s*@TEXT@\\s*|@TEXT@\\s*\\n([=-])+\\s*)$".replace(/@TEXT@/g, header), + "m" + ); + let match = matcher.exec(text); if (match === null) { return null; } else if (match[2]) { // We have a ##-style header. - level = match[2].length; + const level = match[2].length; pattern = "^#{@LEVEL@}\\s*@TEXT@\\s*$\\n+((?:.|\\n)*?(?=^#{1,@LEVEL@}\\s)|.*(?:.|\\n)*)"; pattern = pattern.replace(/@LEVEL@/g, level); @@ -117,7 +108,7 @@ $(document).ready(function () { /* Identify injected URLs which point to markdown files and set their * datatype so that we can register a type handler for them. */ - var cfgs = $(this).data("pat-inject"); + const cfgs = $(this).data("pat-inject"); cfgs.forEach(function (cfg) { if (is_markdown_resource.test(cfg.url)) { cfg.dataType = "markdown"; @@ -131,7 +122,7 @@ inject.registerTypeHandler("markdown", { async sources(cfgs, data) { return await Promise.all( cfgs.map(async function (cfg) { - var pat = Markdown.init(cfg.$target); + const pat = new Markdown(cfg.$target[0]); const rendered = await pat.renderForInjection(cfg, data); return rendered; }) diff --git a/src/pat/markdown/markdown.test.js b/src/pat/markdown/markdown.test.js index 29a940d3c..06bc938c5 100644 --- a/src/pat/markdown/markdown.test.js +++ b/src/pat/markdown/markdown.test.js @@ -16,7 +16,7 @@ describe("pat-markdown", function () { afterEach(() => { jest.restoreAllMocks(); }); - it("replaces the DOM element with the rendered Markdown content.", async function () { + it("It renders content for elements with the pattern trigger.", async function () { var $el = $('

'); $el.appendTo("#lab"); jest.spyOn(pattern.prototype, "render").mockImplementation(() => { @@ -24,10 +24,10 @@ describe("pat-markdown", function () { }); pattern.init($el); await utils.timeout(1); // wait a tick for async to settle. - expect($("#lab").html()).toBe("

Rendering

"); + expect($("#lab").html()).toBe('

Rendering

'); }); - it("does not replace the DOM element if it doesn't have the pattern trigger", function () { + it("It does not render when the DOM element doesn't have the pattern trigger", function () { var $el = $("

"); $el.appendTo("#lab"); jest.spyOn(pattern.prototype, "render").mockImplementation(() => { @@ -70,7 +70,7 @@ describe("pat-markdown", function () { it("converts markdown into HTML", async function () { const $rendering = await pattern.prototype.render("*This is markdown*"); - expect($rendering.html()).toBe("

This is markdown

"); + expect($rendering.html()).toBe(`

This is markdown

\n`); }); }); @@ -159,4 +159,29 @@ describe("pat-markdown", function () { ).toBe("My title\n-------\nContent\n\n"); }); }); + + describe("Code blocks", function () { + it("It correctly renders code blocks", async function () { + document.body.innerHTML = `
+# Title + +some content + +\`\`\`javascript + const foo = "bar"; +\`\`\` +
+`; + + new pattern(document.querySelector(".pat-markdown")); + await utils.timeout(1); // wait a tick for async to settle. + await utils.timeout(1); // wait a tick for async to settle. + + expect(document.body.querySelector(".pat-markdown > h1").textContent).toBe("Title"); // prettier-ignore + expect(document.body.querySelector(".pat-markdown > p").textContent).toBe("some content"); // prettier-ignore + expect(document.body.querySelector(".pat-markdown > pre code")).toBeTruthy(); // prettier-ignore + expect(document.body.querySelector(".pat-markdown > pre.language-javascript code.language-javascript")).toBeTruthy(); // prettier-ignore + expect(document.body.querySelector(".pat-markdown > pre code .hljs-keyword")).toBeTruthy(); // prettier-ignore + }); + }); }); diff --git a/src/pat/sortable/sortable.js b/src/pat/sortable/sortable.js index 6347854e3..8d6a9ea7a 100644 --- a/src/pat/sortable/sortable.js +++ b/src/pat/sortable/sortable.js @@ -15,9 +15,12 @@ export default Base.extend({ trigger: ".pat-sortable", init: function () { + if (window.__patternslib_import_styles) { + import("./_sortable.scss"); + } this.$form = this.$el.closest("form"); this.options = parser.parse(this.$el, false); - this.recordPositions().addHandles().initScrolling(); + this.recordPositions().initScrolling(); this.$el.on("pat-update", this.onPatternUpdate.bind(this)); }, @@ -38,7 +41,10 @@ export default Base.extend({ this.$sortables = this.$el.children().filter(this.options.selector); this.$sortables.each(function (idx) { $(this).data("patterns.sortable", { position: idx }); + // Add `.sortable-item` class to each sortable. + this.classList.add("sortable-item"); }); + this.addHandles(); return this; }, diff --git a/src/pat/switch/switch.js b/src/pat/switch/switch.js index 4688522b6..8dcdc9403 100644 --- a/src/pat/switch/switch.js +++ b/src/pat/switch/switch.js @@ -23,6 +23,7 @@ export default Base.extend({ this.options = this._validateOptions(parser.parse(this.el, this.options, true)); events.add_event_listener(this.el, "click", "pat-switch--on-click", (e) => { + // TODO: e.target.tagName if (e.tagName === "A") { e.preventDefault(); } diff --git a/src/pat/syntax-highlight/index.html b/src/pat/syntax-highlight/index.html index 4a3edbc6b..8dbc0401a 100644 --- a/src/pat/syntax-highlight/index.html +++ b/src/pat/syntax-highlight/index.html @@ -1,23 +1,46 @@ - - pat-syntax-highlight demo page - - - - - -

Sample output:

-
-var pattern = registry.patterns[name];
+  
+    pat-syntax-highlight demo page
+    
+    
+    
+  
+  
+    

Syntax highlight for JavaScript

+
+      
+const pattern = registry.patterns[name];
 if (pattern.transform) {
     try {
         pattern.transform($content);
     } catch (e) {
-        if (dont_catch) { throw(e); }
+        if (dont_catch) {
+          throw(e);
+        }
         log.error("Transform error for pattern" + name, e);
     }
-    
- +} + +
+ +

Syntax highlight for HTML

+
+      
+<h1>hello</h1>
+      
+    
+ +

Syntax highlight for Python

+
+      
+def foo():
+    if not bar:
+          return True
+      
+    
+ + diff --git a/src/pat/syntax-highlight/syntax-highlight.js b/src/pat/syntax-highlight/syntax-highlight.js index 75c7dd94f..2cdf3dd09 100644 --- a/src/pat/syntax-highlight/syntax-highlight.js +++ b/src/pat/syntax-highlight/syntax-highlight.js @@ -1,13 +1,88 @@ -import Base from "../../core/base"; +import { BasePattern } from "../../core/basepattern"; +import Parser from "../../core/parser"; +import registry from "../../core/registry"; import utils from "../../core/utils"; -export default Base.extend({ - name: "syntax-highlight", - trigger: ".pat-syntax-highlight", +export const parser = new Parser("syntax-highlight"); +parser.addArgument("language", "html"); +parser.addArgument("theme", "dark", ["dark", "light"]); +parser.addArgument("features", null, ["line-highlight", "line-numbers"], true); + +class Pattern extends BasePattern { + static name = "syntax-highlight"; + static trigger = ".pat-syntax-highlight"; + parser = parser; async init() { - const Prettify = (await import("google-code-prettify/src/prettify")).default; // prettier-ignore - this.$el.addClass("prettyprint"); - utils.debounce(Prettify.prettyPrint, 50)(); - }, -}); + let _el = this.el; + const code_el = [...this.el.children].filter((it) => it.tagName === "CODE")?.[0]; + if (code_el) { + _el = code_el; + } + + let theme; + if ( + this.options.theme === "light" || + _el.classList.contains("light-bg") || + this.el.classList.contains("light-bg") + ) { + theme = "stackoverflow-light"; + } else if (this.options.theme === "dark") { + theme = "stackoverflow-dark"; + } else { + theme = this.options.theme; + } + + import(`highlight.js/styles/${theme}.css`); + const hljs = (await import("highlight.js")).default; + + // Get the language + let language = [..._el.classList, ...this.el.classList] + .filter((it) => { + return it.startsWith("language-") || it.startsWith("lang-"); + })?.[0] + ?.replace("language-", "") + ?.replace("lang-", ""); + // CSS class language always win. + language = language || this.options.language || "html"; + // Set the language on the code element (ignored if already set) + this.el.classList.add(`language-${language}`); + _el.classList.add(`language-${language}`); + _el.classList.add("hljs"); + + let high; + const value = utils.unescape_html(_el.innerHTML).trim(); + if (language) { + try { + // language to import path mapping + const import_path_mapping = { + atom: "xml", + html: "xml", + plist: "xml", + rss: "xml", + svg: "xml", + wsf: "xml", + xhtml: "xml", + xjb: "xml", + xsd: "xml", + xsl: "xml", + }; + const lang_file = import_path_mapping[language] || language; + const hljs_language = ( + await import(`highlight.js/lib/languages/${lang_file}`) + ).default; + hljs.registerLanguage("javascript", hljs_language); + high = hljs.highlight(value, { language: language }).value; + } catch { + high = hljs.highlightAuto(value).value; + } + } else { + high = hljs.highlightAuto(value).value; + } + _el.innerHTML = high; + } +} + +registry.register(Pattern); +export default Pattern; +export { Pattern }; diff --git a/src/pat/syntax-highlight/syntax-highlight.test.js b/src/pat/syntax-highlight/syntax-highlight.test.js new file mode 100644 index 000000000..22cd45fef --- /dev/null +++ b/src/pat/syntax-highlight/syntax-highlight.test.js @@ -0,0 +1,56 @@ +import pattern from "./syntax-highlight"; +import utils from "../../core/utils"; + +describe("pat-markdown", function () { + beforeEach(function () { + document.body.innerHTML = ""; + }); + + afterEach(function () { + document.body.innerHTML = ""; + }); + + describe("when initialized", function () { + it("it does syntax highlighting.", async function () { + document.body.innerHTML = `
def foo():\n    pass
`; + new pattern(document.querySelector(".pat-syntax-highlight")); + await utils.timeout(1); + expect( + document.querySelector(".pat-syntax-highlight code .hljs-keyword") + ).toBeTruthy(); + // Also adds the .hljs class to the wrapper + expect( + document.querySelector(".pat-syntax-highlight code.hljs") + ).toBeTruthy(); + }); + + it("it does syntax highlighting on any element.", async function () { + document.body.innerHTML = `
def foo():\n pass
`; + new pattern(document.querySelector(".pat-syntax-highlight")); + await utils.timeout(1); + expect( + document.querySelector(".pat-syntax-highlight .hljs-keyword") + ).toBeTruthy(); + // Also adds the .hljs class to the wrapper + expect(document.querySelector(".pat-syntax-highlight.hljs")).toBeTruthy(); + }); + + it("it does syntax highlighting for html.", async function () { + // html code should be escaped for `<`,`>` and `&`. + document.body.innerHTML = `
+                
+                    <h1>foo</h1>
+                
+            
`; + new pattern(document.querySelector(".pat-syntax-highlight")); + await utils.timeout(1); + expect( + document.querySelector(".pat-syntax-highlight code .hljs-tag") + ).toBeTruthy(); + // Also adds the .hljs class to the wrapper + expect( + document.querySelector(".pat-syntax-highlight code.hljs") + ).toBeTruthy(); + }); + }); +}); diff --git a/src/pat/toggle/documentation.md b/src/pat/toggle/documentation.md index 4cf6246e4..8dc32dff7 100644 --- a/src/pat/toggle/documentation.md +++ b/src/pat/toggle/documentation.md @@ -8,7 +8,12 @@ For instance to show or hide a sidebar with a CSS class on the body tag. The _toggle_ pattern can be used to toggle attribute values for objects. It is most commonly used to toggle a CSS class. - Start working + Start working
Working…
@@ -67,3 +72,18 @@ The possible values for the `store` parameter are: - `none`: do not remember the toggle state (default). - `local`: remember the state as part of the local storage. - `session`: remember the status as part of the session storage. + + +### Options reference + +You can customise the behaviour of a switches through options in the +`data-pat-toggle` attribute. + +| Property | Default value | Values | Description | Type | +| ---------- | ------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `selector` | | | CSS selector matching elements where a class or an attribute should be toggled. | String | +| `value` |   |   | One or more space seperated CSS class names to toggle on the element. Can only be used with `attr` set to class, which is the default. | String | +| `event` | `click` | | A JavaScript event which triggers the toggler. The default is to listen to `click` events. | String | +| `attr` | `class` | | The attribute which should be toggled. In case of the default `class`, class names are added or removed. In case of any other attribute the attribute as added or removed. | String | +| `store` | `none` | `none` `session` `local` | How to store the state of a toggle. `none` does not remember the toggle state, `local` stores the state as part of the local storage and `session` stores the status as part of the session storage. | Mutually exclusive | + diff --git a/src/pat/toggle/toggle.js b/src/pat/toggle/toggle.js index 38ccc0b87..735d4638a 100644 --- a/src/pat/toggle/toggle.js +++ b/src/pat/toggle/toggle.js @@ -19,6 +19,8 @@ parser.addArgument("attr", "class"); parser.addArgument("value"); parser.addArgument("store", "none", ["none", "session", "local"]); +parser.addAlias("attribute", "attr"); + export function ClassToggler(values) { this.values = values.slice(0); if (this.values.length > 1) this.values.push(values[0]); diff --git a/src/pat/toggle/toggle.test.js b/src/pat/toggle/toggle.test.js index b0c36ef84..d7b408f70 100644 --- a/src/pat/toggle/toggle.test.js +++ b/src/pat/toggle/toggle.test.js @@ -175,6 +175,16 @@ describe("Pattern implementation", function () { store: "none", }); }); + + it("1.6 - Accept attribute as an alias to attr.", function () { + const instance = new Pattern(document.createElement("div")); + const validated = instance._validateOptions([ + { attribute: "disabled", selector: "input" }, + ]); + expect(validated.length).toEqual(1); + expect(validated[0].attribute).toEqual("disabled"); + expect(validated[0].selector).toEqual("input"); + }); }); describe("2 - When clicking on a toggle", function () { @@ -226,6 +236,18 @@ describe("Pattern implementation", function () { await utils.timeout(1); expect(victims[0].disabled).toBe(false); }); + + it("2.3 - attributes are updated - use the alias", async function () { + var $trigger = $(trigger); + trigger.dataset.patToggle = ".victim; attribute: disabled"; + new Pattern($trigger); + $trigger.click(); + await utils.timeout(1); + expect(victims[0].disabled).toBe(true); + $trigger.click(); + await utils.timeout(1); + expect(victims[0].disabled).toBe(false); + }); }); describe("3 - Toggle event triggers", function () { diff --git a/src/pat/validation/validation.js b/src/pat/validation/validation.js index 4ee831c26..d5383a929 100644 --- a/src/pat/validation/validation.js +++ b/src/pat/validation/validation.js @@ -1,5 +1,6 @@ // Patterns validate - Form vlidation import "../../core/polyfills"; // SubmitEvent.submitter for Safari < 15.4 and jsDOM +import $ from "jquery"; import Base from "../../core/base"; import Parser from "../../core/parser"; import dom from "../../core/dom"; @@ -7,8 +8,8 @@ import events from "../../core/events"; import logging from "../../core/logging"; import utils from "../../core/utils"; -const log = logging.getLogger("pat-validation"); -//log.setLevel(logging.Level.DEBUG); +const logger = logging.getLogger("pat-validation"); +//logger.setLevel(logging.Level.DEBUG); export const parser = new Parser("validation"); parser.addArgument("disable-selector", "[type=submit], button:not([type=button])"); // Elements which must be disabled if there are errors @@ -44,6 +45,32 @@ export default Base.extend({ init() { this.options = parser.parse(this.el, this.options); + events.add_event_listener( + this.el, + "submit", + `pat-validation--submit--validator`, + (e) => { + // On submit, check all. + // Immediate, non-debounced check with submit. Otherwise submit + // is not cancelable. + for (const input of this.inputs) { + logger.debug("Checking input for submit", input, e); + this.check_input({ input: input, event: e }); + } + } + ); + + this.initialize_inputs(); + $(this.el).on("pat-update", () => { + this.initialize_inputs(); + }); + + // Set ``novalidate`` attribute to disable the browser's validation + // bubbles but not disable the validation API. + this.el.setAttribute("novalidate", ""); + }, + + initialize_inputs() { this.inputs = [ ...this.el.querySelectorAll("input[name], select[name], textarea[name]"), ]; @@ -51,24 +78,11 @@ export default Base.extend({ ...this.el.querySelectorAll(this.options.disableSelector), ]; - // Set ``novalidate`` attribute to disable the browser's validation - // bubbles but not disable the validation API. - this.el.setAttribute("novalidate", ""); - for (const [cnt, input] of this.inputs.entries()) { // Cancelable debouncer. const debouncer = utils.debounce((e) => { + logger.debug("Checking input for event", input, e); this.check_input({ input: input, event: e }); - if (this.disabled_elements.some((it) => it.disabled)) { - // If there are already any disabled elements, do a check - // for the whole form. - // This is necessary otherwise the submit button is already - // disabled and no other errors would be shown. - // This is debounced, so it should not disturb too much while typing. - for (const _input of this.inputs.filter((it) => it !== input)) { - this.check_input({ input: _input }); - } - } }, this.options.delay); events.add_event_listener( @@ -90,20 +104,6 @@ export default Base.extend({ (e) => debouncer(e) ); } - - events.add_event_listener( - this.el, - "submit", - `pat-validation--submit--validator`, - (e) => { - // On submit, check all. - // Immediate, non-debounced check with submit. Otherwise submit - // is not cancelable. - for (const input of this.inputs) { - this.check_input({ input: input, event: e }); - } - } - ); }, check_input({ input, event, stop = false }) { @@ -121,7 +121,7 @@ export default Base.extend({ return; } - log.debug(` + logger.debug(` validity_state.badInput ${validity_state.badInput} validity_state.customError ${validity_state.customError} validity_state.patternMismatch ${validity_state.patternMismatch} @@ -155,6 +155,8 @@ export default Base.extend({ }); } else if (input_options.not.after || input_options.not.before) { const msg = input_options.message.date || input_options.message.datetime; + const msg_default_not_before = "The date must be after %{attribute}"; + const msg_default_not_after = "The date must be before %{attribute}"; let not_after; let not_after_el; @@ -199,19 +201,60 @@ export default Base.extend({ const date = new Date(input.value); if (not_after && date > not_after) { - this.set_validity({ input: input, msg: msg }); + let msg_attr; + // Try to construct a meaningfull error message + if (!not_after_el && input_options.not.after) { + // fixed date case + msg_attr = input_options.not.after; + } else { + // Get the label + other text content within the + // label and replace all whitespace and newlines + // with a single space. + msg_attr = not_after_el?.labels?.[0]?.textContent.replace( + /\s\s+/g, // replace all whitespace + " " // with a single space + ); + msg_attr = msg_attr || not_after_el.name; + } + this.set_validity({ + input: input, + msg: msg || msg_default_not_after, + attribute: msg_attr.trim(), + }); } else if (not_before && date < not_before) { - this.set_validity({ input: input, msg: msg }); + let msg_attr; + // Try to construct a meaningfull error message + if (!not_before_el && input_options.not.before) { + // fixed date case + msg_attr = input_options.not.before; + } else { + // Get the label + other text content within the + // label and replace all whitespace and newlines + // with a single space. + msg_attr = not_before_el?.labels?.[0]?.textContent.replace( + /\s\s+/g, // replace all whitespace + " " // with a single space + ); + msg_attr = msg_attr || not_before_el.name; + } + this.set_validity({ + input: input, + msg: msg || msg_default_not_before, + attribute: msg_attr.trim(), + }); } } // always check the other input to clear/set errors - !stop && // do not re-check when stop is set to avoid infinite loops - not_after_el && + // do not re-check when stop is set to avoid infinite loops + if (!stop && not_after_el) { + logger.debug("Check `not-after` input.", not_after_el); this.check_input({ input: not_after_el, stop: true }); - !stop && - not_before_el && + } + if (!stop && not_before_el) { + logger.debug("Check `no-before` input.", not_after_el); this.check_input({ input: not_before_el, stop: true }); + } } if (!validity_state.customError) { @@ -358,10 +401,25 @@ export default Base.extend({ } input[KEY_ERROR_EL] = error_node; + let did_disable = false; for (const it of this.disabled_elements) { if (!it.disabled) { + did_disable = true; it.setAttribute("disabled", "disabled"); it.classList.add("disabled"); + logger.debug("Disable element", it); + } + } + + // Do an initial check of the whole form when a form element (e.g. the + // submit button) was disabled. We want to show the user all possible + // errors at once and after the submit button is disabled there is no + // way to check the whole form at once. ... well we also do not want to + // check the whole form when one input was changed.... + if (did_disable) { + logger.debug("Checking whole form after element was disabled."); + for (const _input of this.inputs.filter((it) => it !== input)) { + this.check_input({ input: _input, stop: true }); } } }, diff --git a/src/pat/validation/validation.test.js b/src/pat/validation/validation.test.js index 07c260e72..8d283e298 100644 --- a/src/pat/validation/validation.test.js +++ b/src/pat/validation/validation.test.js @@ -945,7 +945,7 @@ describe("pat-validation", function () { expect(el.querySelectorAll("em.warning").length).toBe(0); }); - it("5.4 - validates dates with before/after as pattern config attributes with custom error message.", async function () { + it("5.4.1 - validates dates with before/after as pattern config attributes with custom error message.", async function () { document.body.innerHTML = `
+ +
+ `; + + const el = document.querySelector(".pat-validation"); + const inp = el.querySelector("[name=date]"); + + new Pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + // No error when left empty and not required. + inp.value = ""; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + expect(el.querySelectorAll("em.warning").length).toBe(0); + + inp.value = "2010-10-10"; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + expect(el.querySelectorAll("em.warning").length).toBe(1); + expect(el.querySelectorAll("em.warning")[0].textContent).toBe( + "The date must be after 2011-11-11" + ); + + inp.value = "2023-02-23"; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + expect(el.querySelectorAll("em.warning").length).toBe(1); + expect(el.querySelectorAll("em.warning")[0].textContent).toBe( + "The date must be before 2022-02-22" + ); + + inp.value = "2022-01-01"; + inp.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + + expect(el.querySelectorAll("em.warning").length).toBe(0); + }); + + it("5.4.3 - validates dates with before/after as pattern config attributes with NO custom error message, using labels.", async function () { + document.body.innerHTML = ` +
+ + +
+ `; + + const el = document.querySelector(".pat-validation"); + const inp1 = el.querySelector("[name=date1]"); + const inp2 = el.querySelector("[name=date2]"); + + new Pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + inp1.value = "2010-10-10"; + inp2.value = "2001-01-01"; + + inp1.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + expect(el.querySelectorAll("em.warning").length).toBe(2); + + console.log(document.body.innerHTML); + expect(el.querySelectorAll("em.warning")[0].textContent).toBe( + "The date must be before woo date" + ); + + expect(el.querySelectorAll("em.warning")[1].textContent).toBe( + "The date must be after ye date" + ); + }); + + it("5.4.4 - validates dates with before/after as pattern config attributes with NO custom error message, using input names.", async function () { + document.body.innerHTML = ` +
+ + +
+ `; + + const el = document.querySelector(".pat-validation"); + const inp1 = el.querySelector("[name=date1]"); + const inp2 = el.querySelector("[name=date2]"); + + new Pattern(el); + await utils.timeout(1); // wait a tick for async to settle. + + inp1.value = "2010-10-10"; + inp2.value = "2001-01-01"; + + inp1.dispatchEvent(events.change_event()); + await utils.timeout(1); // wait a tick for async to settle. + expect(el.querySelectorAll("em.warning").length).toBe(2); + + console.log(document.body.innerHTML); + expect(el.querySelectorAll("em.warning")[0].textContent).toBe( + "The date must be before date2" + ); + + expect(el.querySelectorAll("em.warning")[1].textContent).toBe( + "The date must be after date1" + ); + }); + it("5.5 - validates dates with before/after constraints", async function () { document.body.innerHTML = `
diff --git a/src/patterns.js b/src/patterns.js index 382d91ef6..32261af9a 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -19,6 +19,7 @@ import "./pat/calendar/calendar"; import "./pat/carousel/carousel"; import "./pat/checklist/checklist"; import "./pat/clone/clone"; +import "./pat/clone-code/clone-code"; import "./pat/collapsible/collapsible"; import "./pat/colour-picker/colour-picker"; import "./pat/date-picker/date-picker"; diff --git a/webpack/webpack.config.js b/webpack/webpack.config.js index 3479035ac..55a516bd9 100644 --- a/webpack/webpack.config.js +++ b/webpack/webpack.config.js @@ -36,11 +36,15 @@ module.exports = () => { remote_entry: config.entry["bundle.min"], dependencies: package_json.dependencies, shared: { - jquery: { + "jquery": { singleton: true, requiredVersion: package_json.dependencies["jquery"], eager: true, }, + "highlight.js": { + singleton: true, + version: package_json.dependencies["highlight.js"], + }, }, }) ); diff --git a/yarn.lock b/yarn.lock index cb70c4f2f..f8bbcc92e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4291,6 +4291,11 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" +dompurify@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz#c9c88390f024c2823332615c9e20a453cf3825dd" + integrity sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA== + dot-prop@^5.1.0: version "5.3.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" @@ -5388,6 +5393,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +highlight.js@<11: + version "10.7.3" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" + integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -6895,6 +6905,11 @@ markdown-it@^12.3.2: mdurl "^1.0.1" uc.micro "^1.0.5" +marked@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/marked/-/marked-4.1.0.tgz#3fc6e7485f21c1ca5d6ec4a39de820e146954796" + integrity sha512-+Z6KDjSPa6/723PQYyc1axYZpYYpDnECDaU6hkaf5gqBieBkMKYReL5hteF2QizhlMbgbo8umXl/clZ67+GlsA== + masonry-layout@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/masonry-layout/-/masonry-layout-4.2.2.tgz#d57b44af13e601bfcdc423f1dd8348b5524de348" @@ -7731,6 +7746,11 @@ pretty-format@^29.1.0: ansi-styles "^5.0.0" react-is "^18.0.0" +prismjs@^1.29.0: + version "1.29.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" + integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"