diff --git a/.eslintrc-patch.js b/.eslintrc-patch.js new file mode 100644 index 0000000..b3c71c1 --- /dev/null +++ b/.eslintrc-patch.js @@ -0,0 +1,61 @@ +const fs = require('fs'); +const path = require('path'); +const projectRootPath = __dirname; +const packageJson = require(path.join(projectRootPath, 'package.json')); + +let voltoPath = './node_modules/@plone/volto'; + +let configFile; +if (fs.existsSync(`${this.projectRootPath}/tsconfig.json`)) + configFile = `${this.projectRootPath}/tsconfig.json`; +else if (fs.existsSync(`${this.projectRootPath}/jsconfig.json`)) + configFile = `${this.projectRootPath}/jsconfig.json`; + +if (configFile) { + const jsConfig = require(configFile).compilerOptions; + const pathsConfig = jsConfig.paths; + if (pathsConfig['@plone/volto']) + voltoPath = `./${jsConfig.baseUrl}/${pathsConfig['@plone/volto'][0]}`; +} + +const AddonConfigurationRegistry = require( + `${voltoPath}/addon-registry.js`, +); +const reg = new AddonConfigurationRegistry(__dirname); + +// Extends ESlint configuration for adding the aliases to `src` directories in Volto addons +const addonAliases = Object.keys(reg.packages).map((o) => [ + o, + reg.packages[o].modulePath, +]); + +const addonExtenders = reg.getEslintExtenders().map((m) => require(m)); + +const defaultConfig = { + extends: `${voltoPath}/.eslintrc`, + settings: { + 'import/resolver': { + alias: { + map: [ + ['@plone/volto', '@plone/volto/src'], + ['@plone/volto-slate', '@plone/volto/packages/volto-slate/src'], + ...addonAliases, + ['@package', `${__dirname}/src`], + ['@root', `${__dirname}/src`], + ['~', `${__dirname}/src`], + ], + extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], + }, + 'babel-plugin-root-import': { + rootPathSuffix: 'src', + }, + }, + }, +}; + +const config = addonExtenders.reduce( + (acc, extender) => extender.modify(acc), + defaultConfig, +); + +module.exports = config; diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 25f99d9..6d120ae 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -4,7 +4,7 @@ on: [push] env: ADDON_NAME: "@kitconcept/volto-slider-block" ADDON_PATH: "volto-slider-block" - VOLTO_VERSION: "17.2.0" + VOLTO_VERSION: "17.6.0" jobs: diff --git a/Makefile b/Makefile index 8363ad2..fb30954 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ RESET=`tput sgr0` YELLOW=`tput setaf 3` PLONE_VERSION=6 -VOLTO_VERSION=17.2.0 +VOLTO_VERSION=17.6.0 ADDON_NAME='@kitconcept/volto-slider-block' ADDON_PATH='volto-slider-block' diff --git a/README.md b/README.md index ec8164e..26a5541 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,31 @@ yarn start Go to http://localhost:3000, login, create a new page. The slider block will show up in the Volto blocks chooser. +## Upgrade Guide + +### `volto-slider-block` 6.0.0 + +The underlying library used by this block has been changed. +Now it uses [Embla Caroussel](https://www.embla-carousel.com). +Embla Caroussel has a similar API and has all the features that `react-slick` had. +Embla is more modern and supported, uses hooks to configure itself and it's extensible using plugins. +It solves all the problems that `react-slick` had, specially in the simplification of the containers and wrappers, and the way it handles the CSS transformations and the width of the elements. + +If you've made any CSS customization, the classNames changed, so you'll need to update the CSS following this table. + +| Old className | New className | +| --------------- | ---------------- | +| slick-slider | slider-wrapper | +| slick-list | slider-viewport | +| slick-track | slider-container | +| slick-slide | slider-slide | +| slick-arrow | slider-button | +| slick-prev | slider-button-prev | +| slick-next | slider-slide-next | +| slick-next | slider-slide-next | +| slick-dots | slider-dots | +| slick-dot | slider-dot | + ## Customization You can use a Volto `schemaEnhancer` to modify the existing block schema. The block also can be extended using Volto's block variations. @@ -210,27 +235,6 @@ export const SliderBlockDataAdapter = ({ }; ``` -## Fix for the limitation in `react-slick` - -The underlying library used in this add-on is `react-slick`. This library has a limitation when used in the Volto Blocks Engine that prevents to enclose properly the slides in the block wrapper. - -To workaround it, it's required to anchor the width to an external element that has the same desired size than the block wrapper. This is set to the default Volto header using a CSS selector ('.container .header') which is the most common use case and can be overriden using the block setting: `referenceContainerQuery` like: - -```js -config.blocks.blocksConfig.slider = { - referenceContainerQuery: 'body.has-sidebar .container .header', -}; -``` - -This fix has an option to adjust this width given a fixed value of pixels via a CSS custom property called `--slider-block-edit-width-adjustment`. -So you can add it in your custom theme, as follows: - -```css -:root { - --slider-block-edit-adjustment: 40px; -} -``` - # Credits Forschungszentrum Jülich diff --git a/dockerfiles/Dockerfile.dev b/dockerfiles/Dockerfile.dev index b2177be..7911495 100644 --- a/dockerfiles/Dockerfile.dev +++ b/dockerfiles/Dockerfile.dev @@ -9,6 +9,7 @@ COPY --chown=node:node package.json /app/src/addons/${ADDON_PATH}/ # Copy linter / prettier configs COPY --chown=node:node .eslintignore* .prettierignore* /app/ +COPY --chown=node:node .eslintrc-patch.js /app/.eslintrc.js RUN < { ); }; -SliderData.propTypes = { - data: PropTypes.objectOf(PropTypes.any).isRequired, - block: PropTypes.string.isRequired, - onChangeBlock: PropTypes.func.isRequired, -}; - export default SliderData; diff --git a/src/components/DotsAndArrows.jsx b/src/components/DotsAndArrows.jsx new file mode 100644 index 0000000..581f4d0 --- /dev/null +++ b/src/components/DotsAndArrows.jsx @@ -0,0 +1,49 @@ +import { Icon } from '@plone/volto/components'; +import rightArrowSVG from '@plone/volto/icons/right-key.svg'; +import leftArrowSVG from '@plone/volto/icons/left-key.svg'; + +export const DotButton = (props) => { + const { children, index, ...restProps } = props; + + return ( + + ); +}; + +export const PrevButton = (props) => { + const { children, ...restProps } = props; + + return ( + + ); +}; + +export const NextButton = (props) => { + const { children, ...restProps } = props; + + return ( + + ); +}; diff --git a/src/components/View.jsx b/src/components/View.jsx index e8cf146..61d9cff 100644 --- a/src/components/View.jsx +++ b/src/components/View.jsx @@ -1,16 +1,12 @@ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Message } from 'semantic-ui-react'; -import Slider from 'react-slick'; +import useEmblaCarousel from 'embla-carousel-react'; import cx from 'classnames'; import { defineMessages, useIntl } from 'react-intl'; import Body from './Body'; import { withBlockExtensions } from '@plone/volto/helpers'; -import { Icon } from '@plone/volto/components'; -import rightArrowSVG from '@plone/volto/icons/right-key.svg'; -import leftArrowSVG from '@plone/volto/icons/left-key.svg'; +import { DotButton, NextButton, PrevButton } from './DotsAndArrows'; import teaserTemplate from '../icons/teaser-template.svg'; -import { SlidesWidthFix, useNodeDimensions } from '../helpers'; -import config from '@plone/volto/registry'; const messages = defineMessages({ PleaseChooseContent: { @@ -20,28 +16,6 @@ const messages = defineMessages({ }, }); -const PrevArrow = ({ className, style, onClick }) => ( - -); - -const NextArrow = ({ className, style, onClick }) => ( - -); - const SliderView = (props) => { const { className, @@ -55,55 +29,76 @@ const SliderView = (props) => { } = props; const intl = useIntl(); - // These are the local state in case of view mode - // The ones that control the edit need to be above since they have - // to be drilled down to here AND to the sidebar - const [slideViewIndex, setSlideViewIndex] = React.useState(0); + const [prevBtnDisabled, setPrevBtnDisabled] = useState(true); + const [nextBtnDisabled, setNextBtnDisabled] = useState(true); + const [selectedIndex, setSelectedIndex] = useState(0); + const [scrollSnaps, setScrollSnaps] = useState([]); + + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); + + const scrollPrev = useCallback(() => { + if (emblaApi) { + emblaApi.scrollPrev(); + setSlideIndex && setSlideIndex(selectedIndex - 1); + } + }, [emblaApi, selectedIndex, setSlideIndex]); + + const scrollNext = useCallback(() => { + if (emblaApi) { + emblaApi.scrollNext(); + setSlideIndex && setSlideIndex(selectedIndex + 1); + } + }, [emblaApi, selectedIndex, setSlideIndex]); - const sliderRef = React.useRef(); + const scrollTo = useCallback( + (index) => { + if (emblaApi) { + emblaApi.scrollTo(index); + setSlideIndex && setSlideIndex(index); + } + }, + [emblaApi, setSlideIndex], + ); - if (sliderRef.current && isEditMode) { + const onInit = useCallback((emblaApi) => { + setScrollSnaps(emblaApi.scrollSnapList()); + }, []); + + const onSelect = useCallback((emblaApi) => { + setSelectedIndex(emblaApi.selectedScrollSnap()); + setPrevBtnDisabled(!emblaApi.canScrollPrev()); + setNextBtnDisabled(!emblaApi.canScrollNext()); + }, []); + + useEffect(() => { + if (!emblaApi) return; + + onInit(emblaApi); + onSelect(emblaApi); + emblaApi.on('reInit', onInit); + emblaApi.on('reInit', onSelect); + emblaApi.on('select', onSelect); + }, [emblaApi, onInit, onSelect]); + + useEffect(() => { // This syncs the current slide with the objectwidget (or other sources // able to access the slider context) // that can modify the SliderContext (and come here via props slideIndex) - sliderRef.current.slickGoTo(slideIndex); - } - - const [headerNode, setHeaderNode] = React.useState(null); - - React.useEffect(() => { - // Unfortunately, we need to go with this ugly hack above the - // dimensions hack for make the slide width work in edit mode as - // we want it. - // The reason is behind how React Portals work and the timing - // around when they are updated. - // What happens is that when the edit route kicks in, this - // component renders and checks for the size of the element slightly - // before the Portal kicks in and renders itself, then the sidebar is - // rendered with its dimensions and pushes the rest to its right position. - // When this happens is late, and the dimensions have been calculated already - // thus the dimensions are wrong (they are the ones before the portal kicks - // is, so they are wider than expected). if (isEditMode) { - setTimeout(() => { - window.scroll(0, 1); - }, 100); + scrollTo(slideIndex); } - }, [isEditMode]); - - React.useEffect(() => { - setHeaderNode( - document.querySelector( - config.blocks.blocksConfig.slider.referenceContainerQuery, - ), - ); - }, []); - const { width } = useNodeDimensions(headerNode); + }, [slideIndex, scrollTo, isEditMode]); + + const sliderContainerWidth = emblaApi + ?.rootNode() + .getBoundingClientRect().width; return ( <> - -
+
{(data.slides?.length === 0 || !data.slides) && isEditMode && (
@@ -113,43 +108,49 @@ const SliderView = (props) => { )} {data.slides?.length > 0 && ( - } - prevArrow={} - slideWidth="1200px" - // This syncs the current slide with the SliderContext state - // responding to the slide change event from the slider itself - // (the dots or the arrows) - afterChange={(current) => isEditMode && setSlideIndex(current)} - beforeChange={(current) => setSlideViewIndex(current)} - > - {data.slides && - data.slides.map((item, index) => { - return ( -
- -
- ); - })} -
+ <> +
+ + + +
+
+ {data.slides && + data.slides.map((item, index) => { + return ( +
+ +
+ ); + })} +
+
+
+ +
+ {scrollSnaps.map((_, index) => ( + scrollTo(index)} + className={'slider-dot'.concat( + index === selectedIndex ? ' slider-dot--selected' : '', + )} + /> + ))} +
+ )}
diff --git a/src/helpers/SlidesWidthFix/SlidesWidthFix.jsx b/src/helpers/SlidesWidthFix/SlidesWidthFix.jsx deleted file mode 100644 index 3e6a33e..0000000 --- a/src/helpers/SlidesWidthFix/SlidesWidthFix.jsx +++ /dev/null @@ -1,18 +0,0 @@ -export const SlidesWidthFix = ({ width }) => { - return ( - - ); -}; - -export default SlidesWidthFix; diff --git a/src/helpers/index.js b/src/helpers/index.js index 0bc15fd..027536a 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -1,16 +1,5 @@ -/** - * Add your helpers here. - * @module helpers - * @example - * export { Api } from './Api/Api'; - */ - import { getTeaserImageURL } from './Image/Image'; import { mergeSchemas } from './Schema/Schema'; -import useNodeDimensions from './useNodeDimensions/useNodeDimensions'; -import SlidesWidthFix from './SlidesWidthFix/SlidesWidthFix'; export { getTeaserImageURL }; export { mergeSchemas }; -export { useNodeDimensions }; -export { SlidesWidthFix }; diff --git a/src/helpers/useNodeDimensions/useNodeDimensions.js b/src/helpers/useNodeDimensions/useNodeDimensions.js deleted file mode 100644 index 75c8111..0000000 --- a/src/helpers/useNodeDimensions/useNodeDimensions.js +++ /dev/null @@ -1,46 +0,0 @@ -import { useState, useEffect } from 'react'; - -function getDimensionObject(node) { - const rect = node.getBoundingClientRect(); - - if (rect.toJSON) { - return rect.toJSON(); - } else { - return { - width: rect.width, - height: rect.height, - top: rect.top || rect.y, - left: rect.left || rect.x, - x: rect.x || rect.left, - y: rect.y || rect.top, - right: rect.right, - bottom: rect.bottom, - }; - } -} - -function useNodeDimensions(node) { - const [dimensions, setDimensions] = useState({}); - - useEffect(() => { - if (node) { - const measure = () => - window.requestAnimationFrame(() => - setDimensions(getDimensionObject(node)), - ); - measure(); - - window.addEventListener('resize', measure); - window.addEventListener('scroll', measure); - - return () => { - window.removeEventListener('resize', measure); - window.removeEventListener('scroll', measure); - }; - } - }, [node]); - - return dimensions; -} - -export default useNodeDimensions; diff --git a/src/index.js b/src/index.js index 6bb65d2..6f2486b 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,6 @@ const applyConfig = (config) => { restricted: false, mostUsed: true, sidebarTab: 1, - referenceContainerQuery: 'body.has-sidebar .container .header', dataAdapter: SliderBlockDataAdapter, }; return config; diff --git a/src/theme/main.less b/src/theme/main.less index d97324d..45e8628 100644 --- a/src/theme/main.less +++ b/src/theme/main.less @@ -1,6 +1,3 @@ -@import (less) '~slick-carousel/slick/slick.css'; -@import (less) '~slick-carousel/slick/slick-theme.css'; - @toolbarWidth: 80px; @sidebarWidth: 375px; @collapsedWidth: 20px; @@ -8,6 +5,106 @@ @slider-images-aspect-ratio: var(--slider-images-aspect-ratio, 16/9); @slider-images-object-position: var(--slider-images-object-position, top left); +:root { + --brand-primary: rgb(47, 112, 193); + --brand-secondary: rgb(116, 97, 195); + --slider-dots-selected-bg: #000; + --slider-dots-bg: #ececec; +} + +.slider-wrapper { + position: relative; + overflow: hidden; +} + +.slider-viewport { + margin-bottom: 24px; +} + +.slider-container { + display: flex; +} + +.slider-slide { + min-width: 0; + flex: 0 0 100%; +} + +.slider-button { + position: absolute; + z-index: 10; + width: 50px; + // Since we are forcing the aspect ratio, if we know the slider container width (we do) + // (we are injecting the CSS property in the main slider wrapper) + // we can infer the height of the image, by using: + height: calc(var(--slider-container-width) * 1 / @slider-images-aspect-ratio); + padding: 0; + border: 0; + margin: 0; + -webkit-appearance: none; + background-color: rgba(0, 0, 0, 0.15); + color: #fff; + cursor: pointer; + opacity: 0; + text-decoration: none; + touch-action: manipulation; + transition: opacity 0.2s ease-in; + + &:hover { + opacity: 1; + } + + &:disabled { + svg { + opacity: 0.3; + } + } +} + +.slider-button-prev { +} + +.slider-button-next { + right: 0; + // In case you want to remove the buttons from the mobile view + // @media only screen and (max-width: $computer-width) { + // display: none !important; + // } +} + +.slider-dot { + display: flex; + width: 46px; + height: 20px; + align-items: center; + padding: 0; + border: 0; + margin: 0; + margin-right: 13px; + -webkit-appearance: none; + background-color: transparent; + cursor: pointer; + text-decoration: none; + touch-action: manipulation; +} + +.slider-dots { + display: flex; + align-items: center; + justify-content: center; +} + +.slider-dot:after { + width: 100%; + height: 6px; + background: var(--slider-dots-bg); + content: ''; +} + +.slider-dot--selected:after { + background: var(--slider-dots-selected-bg); +} + .block.slider { &:not(.inner):not([role='presentation']) { padding-bottom: 4em; diff --git a/yarn.lock b/yarn.lock index e2778a0..c9a6507 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3017,6 +3017,7 @@ __metadata: "@babel/eslint-parser": 7.22.15 "@plone/scripts": ^3.0.0 deepmerge: 4.2.2 + embla-carousel-react: ^8.0.0-rc15 eslint: 8.49.0 eslint-config-prettier: 9.0.0 eslint-config-react-app: 7.0.1 @@ -3029,9 +3030,7 @@ __metadata: eslint-plugin-react-hooks: 4.6.0 postcss-less: 6.0.0 prettier: 3.0.3 - react-slick: 0.29.0 release-it: ^16.2.1 - slick-carousel: 1.8.1 stylelint: 15.10.3 stylelint-config-idiomatic-order: 9.0.0 stylelint-config-prettier: 9.0.4 @@ -4345,13 +4344,6 @@ __metadata: languageName: node linkType: hard -"classnames@npm:^2.2.5": - version: 2.3.2 - resolution: "classnames@npm:2.3.2" - checksum: 2c62199789618d95545c872787137262e741f9db13328e216b093eea91c85ef2bfb152c1f9e63027204e2559a006a92eb74147d46c800a9f96297ae1d9f96f4e - languageName: node - linkType: hard - "cli-boxes@npm:^3.0.0": version: 3.0.0 resolution: "cli-boxes@npm:3.0.0" @@ -4878,6 +4870,34 @@ __metadata: languageName: node linkType: hard +"embla-carousel-react@npm:^8.0.0-rc15": + version: 8.0.0-rc15 + resolution: "embla-carousel-react@npm:8.0.0-rc15" + dependencies: + embla-carousel: 8.0.0-rc15 + embla-carousel-reactive-utils: 8.0.0-rc15 + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 + checksum: 09d7cc487526b6552bbbc5f787045c9e0e11b112897a779276c6b0e8bf872a4bcb8becf18c3c1f52ffa56ae203f043dc03a677733e4ed32cf0859088aa7d02b7 + languageName: node + linkType: hard + +"embla-carousel-reactive-utils@npm:8.0.0-rc15": + version: 8.0.0-rc15 + resolution: "embla-carousel-reactive-utils@npm:8.0.0-rc15" + peerDependencies: + embla-carousel: 8.0.0-rc15 + checksum: 3d0a4f2389d3b45b03d2667e6303d9bc7fc87aeea9767e903901029102b4e55f0d48e717a5cdcffbe9e71769a0f4f6cba96fbf8389475d84eb1f895626047246 + languageName: node + linkType: hard + +"embla-carousel@npm:8.0.0-rc15": + version: 8.0.0-rc15 + resolution: "embla-carousel@npm:8.0.0-rc15" + checksum: c66fa69e73739c2638d593dfd00d9a30242651c6b4d2487cb9691de58a12ea150e9166f58936c552c045f77db47c869faa2c9701cdc3c49224da5a383315c784 + languageName: node + linkType: hard + "emoji-regex@npm:^10.2.1": version: 10.3.0 resolution: "emoji-regex@npm:10.3.0" @@ -4899,13 +4919,6 @@ __metadata: languageName: node linkType: hard -"enquire.js@npm:^2.1.6": - version: 2.1.6 - resolution: "enquire.js@npm:2.1.6" - checksum: bb094054ee2768edafc3b80fb2b65b79d63160d625085162e9c9016843b6eaa13d7430201bf593c2e31e1725b3208f47f57aa27e03b19729e7590d1ca2241451 - languageName: node - linkType: hard - "error-ex@npm:^1.3.1": version: 1.3.2 resolution: "error-ex@npm:1.3.2" @@ -6955,15 +6968,6 @@ __metadata: languageName: node linkType: hard -"json2mq@npm:^0.2.0": - version: 0.2.0 - resolution: "json2mq@npm:0.2.0" - dependencies: - string-convert: ^0.2.0 - checksum: 5672c3abdd31e21a0e2f0c2688b4948103687dab949a1c5a1cba98667e899a96c2c7e3d71763c4f5e7cd7d7c379ea5dd5e1a9b2a2107dd1dfa740719a11aa272 - languageName: node - linkType: hard - "json5@npm:^0.5.0": version: 0.5.1 resolution: "json5@npm:0.5.1" @@ -8223,22 +8227,6 @@ __metadata: languageName: node linkType: hard -"react-slick@npm:0.29.0": - version: 0.29.0 - resolution: "react-slick@npm:0.29.0" - dependencies: - classnames: ^2.2.5 - enquire.js: ^2.1.6 - json2mq: ^0.2.0 - lodash.debounce: ^4.0.8 - resize-observer-polyfill: ^1.5.0 - peerDependencies: - react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 - react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 - checksum: 67ce4981914da8a76c6efb4bc8ee7ab69e04c33bd4b3517419ab2165679a3c701dae781556149713fe2c542f84188b7ca101ea58883537be5c05756408f48c8b - languageName: node - linkType: hard - "read-pkg-up@npm:^8.0.0": version: 8.0.0 resolution: "read-pkg-up@npm:8.0.0" @@ -8484,13 +8472,6 @@ __metadata: languageName: node linkType: hard -"resize-observer-polyfill@npm:^1.5.0": - version: 1.5.1 - resolution: "resize-observer-polyfill@npm:1.5.1" - checksum: 57e7f79489867b00ba43c9c051524a5c8f162a61d5547e99333549afc23e15c44fd43f2f318ea0261ea98c0eb3158cca261e6f48d66e1ed1cd1f340a43977094 - languageName: node - linkType: hard - "resolve-alpn@npm:^1.2.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1" @@ -8903,15 +8884,6 @@ __metadata: languageName: node linkType: hard -"slick-carousel@npm:1.8.1": - version: 1.8.1 - resolution: "slick-carousel@npm:1.8.1" - peerDependencies: - jquery: ">=1.8.0" - checksum: acaad391e4d8bc1c7fdb8d361faa1f1d60829b31d618b54bc38c0550a59b26de36537e0ab4bc0364176ec11d1a61d0cf11e99d8d5b1285d656673c9a1a719257 - languageName: node - linkType: hard - "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -9011,13 +8983,6 @@ __metadata: languageName: node linkType: hard -"string-convert@npm:^0.2.0": - version: 0.2.1 - resolution: "string-convert@npm:0.2.1" - checksum: 1098b1d8e3712c72d0a0b0b7f5c36c98af93e7660b5f0f14019e41bcefe55bfa79214d5e03e74d98a7334a0b9bf2b7f4c6889c8c24801aa2ae2f9ebe1d8a1ef9 - languageName: node - linkType: hard - "string-natural-compare@npm:^3.0.1": version: 3.0.1 resolution: "string-natural-compare@npm:3.0.1"