diff --git a/src/generate-html.js b/src/generate-html.js index 535daaf..922be21 100644 --- a/src/generate-html.js +++ b/src/generate-html.js @@ -1,12 +1,9 @@ const { escapeAttribute } = require("entities"); -const DEFAULT_ATTRIBUTES = { - // loading: "lazy", - // decoding: "async", -}; - const LOWSRC_FORMAT_PREFERENCE = ["jpeg", "png", "gif", "svg", "webp", "avif"]; +const CHILDREN_OBJECT_KEY = "@children"; + function generateSrcset(metadataFormatEntry) { if(!Array.isArray(metadataFormatEntry)) { return ""; @@ -19,19 +16,23 @@ function generateSrcset(metadataFormatEntry) { Returns: e.g. { img: { alt: "", src: "" } e.g. { img: { alt: "", src: "", srcset: "", sizes: "" } } - e.g. { picture: [ - { source: { srcset: "", sizes: "" } }, - { source: { srcset: "", sizes: "" } }, - { img: { alt: "", src: "", srcset: "", sizes: "" } }, - ]} + e.g. { picture: { + class: "", + @children: [ + { source: { srcset: "", sizes: "" } }, + { source: { srcset: "", sizes: "" } }, + { img: { alt: "", src: "", srcset: "", sizes: "" } }, + ] + } */ -function generateObject(metadata, userDefinedAttributes = {}) { - let attributes = Object.assign({}, DEFAULT_ATTRIBUTES, userDefinedAttributes); +function generateObject(metadata, userDefinedImgAttributes = {}, userDefinedPictureAttributes = {}) { + let imgAttributes = Object.assign({}, userDefinedImgAttributes); + let pictureAttributes = Object.assign({}, userDefinedPictureAttributes); // The attributes.src gets overwritten later on. Save it here to make the error outputs less cryptic. - let originalSrc = attributes.src; + let originalSrc = imgAttributes.src; - if(attributes.alt === undefined) { + if(imgAttributes.alt === undefined) { // You bet we throw an error on missing alt (alt="" works okay) throw new Error(`Missing \`alt\` attribute on eleventy-img shortcode from: ${originalSrc}`); } @@ -68,37 +69,37 @@ function generateObject(metadata, userDefinedAttributes = {}) { throw new Error(`Could not find the lowest source for responsive markup for ${originalSrc}`); } - attributes.src = lowsrc[0].url; - attributes.width = lowsrc[lowsrc.length - 1].width; - attributes.height = lowsrc[lowsrc.length - 1].height; + imgAttributes.src = lowsrc[0].url; + imgAttributes.width = lowsrc[lowsrc.length - 1].width; + imgAttributes.height = lowsrc[lowsrc.length - 1].height; - let attributesWithoutSizes = Object.assign({}, attributes); - delete attributesWithoutSizes.sizes; + let imgAttributesWithoutSizes = Object.assign({}, imgAttributes); + delete imgAttributesWithoutSizes.sizes; // : one format and one size if(entryCount === 1) { return { - img: attributesWithoutSizes + img: imgAttributesWithoutSizes }; } // Per the HTML specification sizes is required srcset is using the `w` unit // https://html.spec.whatwg.org/dev/semantics.html#the-link-element:attr-link-imagesrcset-4 // Using the default "100vw" is okay - let missingSizesErrorMessage = `Missing \`sizes\` attribute on eleventy-img shortcode from: ${originalSrc || attributes.src}`; + let missingSizesErrorMessage = `Missing \`sizes\` attribute on eleventy-img shortcode from: ${originalSrc || imgAttributes.src}. This is only required when using multiple output widths for an image.`; // : one format and multiple sizes if(formats.length === 1) { // implied entryCount > 1 - if(entryCount > 1 && !attributes.sizes) { + if(entryCount > 1 && !imgAttributes.sizes) { throw new Error(missingSizesErrorMessage); } - let imgAttributes = Object.assign({}, attributesWithoutSizes); - imgAttributes.srcset = generateSrcset(lowsrc); - imgAttributes.sizes = attributes.sizes; + let imgAttributesCopy = Object.assign({}, imgAttributesWithoutSizes); + imgAttributesCopy.srcset = generateSrcset(lowsrc); + imgAttributesCopy.sizes = imgAttributes.sizes; return { - img: imgAttributes + img: imgAttributesCopy }; } @@ -106,7 +107,7 @@ function generateObject(metadata, userDefinedAttributes = {}) { values.filter(imageFormat => { return imageFormat.length > 0 && (lowsrcFormat !== imageFormat[0].format); }).forEach(imageFormat => { - if(imageFormat.length > 1 && !attributes.sizes) { + if(imageFormat.length > 1 && !imgAttributes.sizes) { throw new Error(missingSizesErrorMessage); } @@ -115,8 +116,8 @@ function generateObject(metadata, userDefinedAttributes = {}) { srcset: generateSrcset(imageFormat), }; - if(attributes.sizes) { - sourceAttrs.sizes = attributes.sizes; + if(imgAttributes.sizes) { + sourceAttrs.sizes = imgAttributes.sizes; } children.push({ @@ -130,37 +131,45 @@ function generateObject(metadata, userDefinedAttributes = {}) { If we have more than one size, we can use srcset and sizes. If the browser doesn't support those attributes, it should ignore them. */ - let imgAttributes = Object.assign({}, attributesWithoutSizes); + let imgAttributesForPicture = Object.assign({}, imgAttributesWithoutSizes); if (lowsrc.length > 1) { - if (!attributes.sizes) { + if (!imgAttributes.sizes) { // Per the HTML specification sizes is required srcset is using the `w` unit // https://html.spec.whatwg.org/dev/semantics.html#the-link-element:attr-link-imagesrcset-4 // Using the default "100vw" is okay throw new Error(missingSizesErrorMessage); } - imgAttributes.srcset = generateSrcset(lowsrc); - imgAttributes.sizes = attributes.sizes; + imgAttributesForPicture.srcset = generateSrcset(lowsrc); + imgAttributesForPicture.sizes = imgAttributes.sizes; } children.push({ - "img": imgAttributes + "img": imgAttributesForPicture }); return { - "picture": children + "picture": { + ...pictureAttributes, + [CHILDREN_OBJECT_KEY]: children, + } }; } function mapObjectToHTML(tagName, attrs = {}) { let attrHtml = Object.entries(attrs).map(entry => { let [key, value] = entry; + if(key === CHILDREN_OBJECT_KEY) { + return false; + } + + // Issue #82 if(key === "alt") { return `${key}="${value ? escapeAttribute(value) : ""}"`; } return `${key}="${value}"`; - }).join(" "); + }).filter(keyPair => Boolean(keyPair)).join(" "); return `<${tagName}${attrHtml ? ` ${attrHtml}` : ""}>`; } @@ -168,18 +177,25 @@ function mapObjectToHTML(tagName, attrs = {}) { function generateHTML(metadata, attributes = {}, options = {}) { let isInline = options.whitespaceMode !== "block"; let markup = []; + let obj = generateObject(metadata, attributes); for(let tag in obj) { - if(!Array.isArray(obj[tag])) { - markup.push(mapObjectToHTML(tag, obj[tag])); - } else { - // - markup.push(mapObjectToHTML(tag, options.pictureAttributes || {})); + let tagAttributes = obj[tag]; + // We probably don’t need `options.pictureAttributes` any more but we’ll keep it around for backwards compatibility + // Picture attributes are provided for-free by the transform plugin. + if(tag === "picture" && options.pictureAttributes) { + tagAttributes = Object.assign({}, tagAttributes, options.pictureAttributes); + } + markup.push(mapObjectToHTML(tag, tagAttributes)); - for(let child of obj[tag]) { + // + + if(Array.isArray(obj[tag]?.[CHILDREN_OBJECT_KEY])) { + for(let child of obj[tag][CHILDREN_OBJECT_KEY]) { let childTagName = Object.keys(child)[0]; markup.push((!isInline ? " " : "") + mapObjectToHTML(childTagName, child[childTagName])); } + markup.push(``); } } diff --git a/src/image-attrs-to-posthtml-node.js b/src/image-attrs-to-posthtml-node.js index 5288534..9f6dc47 100644 --- a/src/image-attrs-to-posthtml-node.js +++ b/src/image-attrs-to-posthtml-node.js @@ -3,14 +3,29 @@ const Util = require("./util.js"); const ATTR_PREFIX = "eleventy:"; +const CHILDREN_OBJECT_KEY = "@children"; + const ATTR = { IGNORE: `${ATTR_PREFIX}ignore`, WIDTHS: `${ATTR_PREFIX}widths`, FORMATS: `${ATTR_PREFIX}formats`, OUTPUT: `${ATTR_PREFIX}output`, OPTIONAL: `${ATTR_PREFIX}optional`, + PICTURE: `${ATTR_PREFIX}pictureattr:`, }; +function getPictureAttributes(attrs = {}) { + let pictureAttrs = {}; + for(let key in attrs) { + // hoists to ` + // e.g. hoists to + if(key.startsWith(ATTR.PICTURE)) { + pictureAttrs[key.slice(ATTR.PICTURE.length)] = attrs[key]; + } + } + return pictureAttrs; +} + function convertToPosthtmlNode(obj) { // node.tag // node.attrs @@ -20,12 +35,21 @@ function convertToPosthtmlNode(obj) { let [key] = Object.keys(obj); node.tag = key; - if(Array.isArray(obj[key])) { - node.content = obj[key].map(child => { - return convertToPosthtmlNode(child); - }); - } else { - node.attrs = obj[key]; + let children = obj[key]?.[CHILDREN_OBJECT_KEY]; + let attributes = {}; + for(let attrKey in obj[key]) { + if(attrKey !== CHILDREN_OBJECT_KEY) { + attributes[attrKey] = obj[key][attrKey]; + } + } + node.attrs = attributes; + + if(Array.isArray(children)) { + node.content = obj[key]?.[CHILDREN_OBJECT_KEY] + .filter(child => Boolean(child)) + .map(child => { + return convertToPosthtmlNode(child); + }); } return node; @@ -63,12 +87,15 @@ async function imageAttributesToPosthtmlNode(attributes, instanceOptions, global Util.addConfig(globalPluginOptions.eleventyConfig, options); let metadata = await eleventyImage(attributes.src, options); + + let pictureAttributes = getPictureAttributes(attributes); + cleanAttrs(attributes); let imageAttributes = Object.assign({}, globalPluginOptions.defaultAttributes, attributes); // You bet we throw an error on missing alt in `imageAttributes` (alt="" works okay) - let obj = await eleventyImage.generateObject(metadata, imageAttributes); + let obj = await eleventyImage.generateObject(metadata, imageAttributes, pictureAttributes); return convertToPosthtmlNode(obj); } diff --git a/src/transform-plugin.js b/src/transform-plugin.js index 2d86dac..a3bd57a 100644 --- a/src/transform-plugin.js +++ b/src/transform-plugin.js @@ -10,27 +10,50 @@ const ATTRS = { ORIGINAL_SOURCE: "eleventy:internal_original_src", }; -function transformTag(context, node, opts) { - let originalSource = node.attrs?.src; +function getSourcePath(sourceNode/*, rootTargetNode*/) { + // Debatable TODO: use rootTargetNode (if `picture`) to retrieve a potentially higher quality source from + return sourceNode.attrs?.src; +} + +function assignAttributes(rootTargetNode, newNode) { + // only copy attributes if old and new tag name are the same (picture => picture, img => img) + if(rootTargetNode.tag !== newNode.tag) { + delete rootTargetNode.attrs; + } + + if(!rootTargetNode.attrs) { + rootTargetNode.attrs = {}; + } + + // Copy all new attributes to target + if(newNode.attrs) { + Object.assign(rootTargetNode.attrs, newNode.attrs); + } +} + +function transformTag(context, sourceNode, rootTargetNode, opts) { + let originalSource = getSourcePath(sourceNode, rootTargetNode); + if(!originalSource) { - return node; + return sourceNode; } let { inputPath, outputPath, url } = context.page; - node.attrs.src = Util.normalizeImageSource({ + sourceNode.attrs.src = Util.normalizeImageSource({ input: opts.directories.input, inputPath, }, originalSource, { isViaHtml: true, // this reference came from HTML, so we can decode the file name }); - if(node.attrs.src !== originalSource) { - node.attrs[ATTRS.ORIGINAL_SOURCE] = originalSource; + + if(sourceNode.attrs.src !== originalSource) { + sourceNode.attrs[ATTRS.ORIGINAL_SOURCE] = originalSource; } let instanceOptions = {}; - let outputDirectory = getOutputDirectory(node); + let outputDirectory = getOutputDirectory(sourceNode); if(outputDirectory) { if(path.isAbsolute(outputDirectory)) { instanceOptions = { @@ -60,29 +83,32 @@ function transformTag(context, node, opts) { } // returns promise - return imageAttributesToPosthtmlNode(node.attrs, instanceOptions, opts).then(obj => { - // TODO how to assign attributes to `` only - // Wipe out attrs just in case this is - node.attrs = {}; + return imageAttributesToPosthtmlNode(sourceNode.attrs, instanceOptions, opts).then(newNode => { + // node.tag + // node.attrs + // node.content + + assignAttributes(rootTargetNode, newNode); - Object.assign(node, obj); + rootTargetNode.tag = newNode.tag; + rootTargetNode.content = newNode.content; }, (error) => { - if(isOptional(node) || !opts.failOnError) { - if(isOptional(node, "keep")) { + if(isOptional(sourceNode) || !opts.failOnError) { + if(isOptional(sourceNode, "keep")) { // replace with the original source value, no image transformation is taking place - if(node.attrs[ATTRS.ORIGINAL_SOURCE]) { - node.attrs.src = node.attrs[ATTRS.ORIGINAL_SOURCE]; + if(sourceNode.attrs[ATTRS.ORIGINAL_SOURCE]) { + sourceNode.attrs.src = sourceNode.attrs[ATTRS.ORIGINAL_SOURCE]; } // leave as-is, likely 404 when a user visits the page - } else if(isOptional(node, "placeholder")) { + } else if(isOptional(sourceNode, "placeholder")) { // transparent png - node.attrs.src = PLACEHOLDER_DATA_URI; - } else if(isOptional(node)) { - delete node.attrs.src; + sourceNode.attrs.src = PLACEHOLDER_DATA_URI; + } else if(isOptional(sourceNode)) { + delete sourceNode.attrs.src; } // optional or don’t fail on error - cleanTag(node); + cleanTag(sourceNode); return Promise.resolve(); } @@ -113,14 +139,30 @@ function eleventyImageTransformPlugin(eleventyConfig, options = {}) { function posthtmlPlugin(context) { return async (tree) => { let promises = []; - tree.match({ tag: 'img' }, (node) => { - if(isIgnored(node) || node?.attrs?.src?.startsWith("data:")) { - cleanTag(node); + let match = tree.match; + + tree.match({ tag: 'picture' }, pictureNode => { + match.call(pictureNode, { tag: 'img' }, imgNode => { + imgNode._insideOfPicture = true; + + promises.push(transformTag(context, imgNode, pictureNode, opts)); + + return imgNode; + }); + + return pictureNode; + }); + + tree.match({ tag: 'img' }, (imgNode) => { + if(imgNode._insideOfPicture || isIgnored(imgNode) || imgNode?.attrs?.src?.startsWith("data:")) { + cleanTag(imgNode); + + delete imgNode._insideOfPicture; } else { - promises.push(transformTag(context, node, opts)); + promises.push(transformTag(context, imgNode, imgNode, opts)); } - return node; + return imgNode; }); await Promise.all(promises); diff --git a/test/bio-2017.webp b/test/bio-2017.webp new file mode 100644 index 0000000..b23f327 Binary files /dev/null and b/test/bio-2017.webp differ diff --git a/test/test-markup.js b/test/test-markup.js index b889226..a0dfc3a 100644 --- a/test/test-markup.js +++ b/test/test-markup.js @@ -52,22 +52,24 @@ test("Image object (defaults)", async t => { t.deepEqual(generateObject(results, { alt: "" }), { - "picture": [ - { - "source": { - type: "image/webp", - srcset: "/img/KkPMmHd3hP-1280.webp 1280w", - } - }, - { - "img": { - alt: "", - src: "/img/KkPMmHd3hP-1280.jpeg", - width: 1280, - height: 853, + "picture": { + "@children": [ + { + "source": { + type: "image/webp", + srcset: "/img/KkPMmHd3hP-1280.webp 1280w", + } + }, + { + "img": { + alt: "", + src: "/img/KkPMmHd3hP-1280.jpeg", + width: 1280, + height: 853, + } } - } - ] + ] + } }); }); diff --git a/test/transform-test.mjs b/test/transform-test.mjs index 4fc12cd..a3c2380 100644 --- a/test/transform-test.mjs +++ b/test/transform-test.mjs @@ -89,7 +89,7 @@ test("Using the transform plugin with transform on request during dev mode (with }); let results = await elev.toJSON(); - t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); + t.is(normalizeEscapedPaths(results[0].content), `My ugly mug`); }); @@ -114,7 +114,7 @@ test("Using the transform plugin with transform on request during dev mode but d }); let results = await elev.toJSON(); - t.is(results[0].content, `My ugly mug`); + t.is(results[0].content, `My ugly mug`); }); test("Throw a good error with a bad remote image request", async t => { @@ -331,11 +331,95 @@ test("Using the transform plugin, #257", async t => { eleventyConfig.addTemplate("virtual.html", `My ugly mug`); eleventyConfig.addPlugin(eleventyImageTransformPlugin, { - dryRun: true // don’t write image files! + dryRun: true, // don’t write image files! }); } }); let results = await elev.toJSON(); t.is(results[0].content, `My ugly mug`); +}); + +test("Using the transform plugin, to #214", async t => { + let elev = new Eleventy( "test", "test/_site", { + config: eleventyConfig => { + eleventyConfig.addTemplate("virtual.html", `My ugly mug`); + + eleventyConfig.addPlugin(eleventyImageTransformPlugin, { + dryRun: true, // don’t write image files! + }); + } + }); + elev.disableLogger(); + + let results = await elev.toJSON(); + t.is(results[0].content, `My ugly mug`); +}); + +test("Using the transform plugin, to #214", async t => { + let elev = new Eleventy( "test", "test/_site", { + config: eleventyConfig => { + // Uses only the right now, see the debatable TODO in transform-plugin.js->getSourcePath + eleventyConfig.addTemplate("virtual.html", `My ugly mug`); + + eleventyConfig.addPlugin(eleventyImageTransformPlugin, { + formats: ["auto"], + dryRun: true, // don’t write image files! + }); + } + }); + elev.disableLogger(); + + let results = await elev.toJSON(); + t.is(results[0].content, `My ugly mug`); +}); + +test("Using the transform plugin, to #214", async t => { + let elev = new Eleventy( "test", "test/_site", { + config: eleventyConfig => { + eleventyConfig.addTemplate("virtual.html", `My ugly mug`); + + eleventyConfig.addPlugin(eleventyImageTransformPlugin, { + dryRun: true, // don’t write image files! + }); + } + }); + elev.disableLogger(); + + let results = await elev.toJSON(); + t.is(results[0].content, `My ugly mug`); +}); + +test("Using the transform plugin, to , keeps slot attribute #241", async t => { + let elev = new Eleventy( "test", "test/_site", { + config: eleventyConfig => { + eleventyConfig.addTemplate("virtual.html", `My ugly mug`); + + eleventyConfig.addPlugin(eleventyImageTransformPlugin, { + formats: ["auto"], + dryRun: true, // don’t write image files! + }); + } + }); + elev.disableLogger(); + + let results = await elev.toJSON(); + t.is(results[0].content, `My ugly mug`); +}); + +test("Using the transform plugin, to , keeps slot attribute #241", async t => { + let elev = new Eleventy( "test", "test/_site", { + config: eleventyConfig => { + eleventyConfig.addTemplate("virtual.html", `My ugly mug`); + + eleventyConfig.addPlugin(eleventyImageTransformPlugin, { + dryRun: true, // don’t write image files! + }); + } + }); + elev.disableLogger(); + + let results = await elev.toJSON(); + // TODO how to add independent class to + t.is(results[0].content, `My ugly mug`); }); \ No newline at end of file