Compare commits

..

4 Commits

Author SHA1 Message Date
mira 2df79a8feb add self links to package.json 2026-08-25 17:32:02 +03:00
mira 9c70764d97 add usage example 2026-08-23 19:29:23 +03:00
mira 97ff2d2f24 preparing to publication 2026-08-23 18:58:33 +03:00
Mira e7937bd51b clean up 2026-08-04 21:57:46 +03:00
10 changed files with 86 additions and 7065 deletions
View File
+75
View File
@@ -0,0 +1,75 @@
# Tailwind Dashed Border SVG
A plugin for addressing issue with customizable and animatable dashed border via tailwind interface (mostly).
It uses real svg under the hood what provide the maximum flexibility.
# Setup
for the tailwind v4 use this in your css file:
```css
@import "tailwindcss";
@plugin "tailwind-dashed-border";
```
then prepare a component for svg in case you don't want repeat yourself everywhere. I will show it as it would look in React, but similar approach should work in any framework.
```tsx
export function BorderSVG() {
return (
<svg className="dashed-border-svg" aria-hidden="true">
<rect className="dashed-border-rect" />
</svg>
);
}
```
and now you are ready to make your borders
```tsx
<div className="dashed-border">
<BorderSVG/>
</div>
```
# Usage Example
```tsx
<div
className="
px-14 py-10 bg-purple-900 rounded-2xl text-2xl
dashed-border dashed-border-width-5 dashed-border-color-orange-600
dashed-border-align-edge
duration-300 ease-in-out transition-all
dashed-border-gap-0 dashed-border-length-9
hover:dashed-border-gap-3 hover:dashed-border-length-6
dashed-border-offset-2.5 hover:dashed-border-offset-1
"
>
<DashedBorderSvg />
<div>Content</div>
</div>
```
![output](./assets/example.webp)
# Plugin Properties
## Setup
- `dashed-border`
- `dashed-border-svg`
- `dashed-border-rect`
## Configuration
- `dashed-border-color-[color]`
- `dashed-border-width-[spacing]`
- `dashed-border-length-[spacing]`
- `dashed-border-gap-[spacing]`
- `dashed-border-offset-[spacing]`
- `dashed-border-align-["inside"/"edge"/"outside"]`
- `dashed-border-align-[spacing]` (0 - inside, 1 - outside, etc.)
## Vanilla extended
- `duration-[time]`
- `[timing function]` (`easing-linear`, `ease-in-out`, etc.)
- `transition-all` - animation may not work without it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

-6509
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -2,6 +2,8 @@
"name": "tailwind-dashed-border", "name": "tailwind-dashed-border",
"version": "0.1.0", "version": "0.1.0",
"description": "Configurable dashed border utility for Tailwind CSS v4", "description": "Configurable dashed border utility for Tailwind CSS v4",
"author": "Mira Gluhih",
"contributors": [],
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -19,6 +21,10 @@
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit" "check": "tsc -p tsconfig.json --noEmit"
}, },
"repository": {
"type": "git",
"url": "https://github.com/ArsenijSerjogin/tailwind-dashed-border.git"
},
"keywords": [ "keywords": [
"tailwindcss", "tailwindcss",
"tailwind", "tailwind",
@@ -36,5 +42,9 @@
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"license": "MIT" "license": "MIT",
"bugs": {
"url": "https://github.com/ArsenijSerjogin/tailwind-dashed-border/issues"
},
"homepage": "https://github.com/ArsenijSerjogin/tailwind-dashed-border"
} }
-63
View File
@@ -1,63 +0,0 @@
# Tailwind Dashed Border SVG
A plugin for adressing issue with custamable and animatable dashed border via tailwind interface (mostly).
It uses real svg under the hood what provide the maximum flexibility. Non animated border coming soon.
# Setup
prepare a component for svg
```tsx
export function BorderSVG() {
return (
<svg className="dashed-border-svg" aria-hidden="true">
<rect className="dashed-border-rect" />
</svg>
);
}
export function BorderBottomLineSVG() {
return (
<svg className="dashed-border-svg" aria-hidden="true">
<line
className="dashed-border-bottom-line"
x1="0"
x2="100%"
y1="100%"
y2="100%"
/>
</svg>
);
}
```
and now you are ready to make your borders
```tsx
<div className="dashed-border">
<BorderSVG/>
</div>
```
# Properties
## Setup
- dashed-border
- dashed-border-svg
- dashed-border-rect
- dashed-border-bottom-line
## Configuration
- dashed-border-color-[color]
- dashed-border-width-[spacing]
- dashed-border-length-[spacing]
- dashed-border-gap-[spacing]
- dashed-border-offset-[spacing]
- dashed-border-align-["inside"/"edge"/"outside"]
- dashed-border-align-[spacing] (0 - inside, toward the outside)
## Vanilla extended
- duration-[time]
- [timing function] (easing-linear, ease-in-out, etc.)
- transition-all
-32
View File
@@ -163,15 +163,9 @@ function toCssVariable(
const dashedBorderPlugin: ReturnType<typeof plugin> = plugin( const dashedBorderPlugin: ReturnType<typeof plugin> = plugin(
({ addBase, addUtilities, theme, ...pluginApi }) => { ({ addBase, addUtilities, theme, ...pluginApi }) => {
// const matchUtilities = pluginApi.matchUtilities;
const matchUtilities = betterMatchUtility(pluginApi.matchUtilities); const matchUtilities = betterMatchUtility(pluginApi.matchUtilities);
const DEFAULT_GAP = "0.5rem";
const DEFAULT_LENGTH = "0.75rem";
const DEFAULT_OFFSET = "0px";
const DEFAULT_WIDTH = "1px";
const DEFAULT_COLOR = "currentColor"; const DEFAULT_COLOR = "currentColor";
const DEFAULT_RADIUS = "0px";
addBase({ addBase({
"@property --tw-dashed-border-gap": { "@property --tw-dashed-border-gap": {
@@ -216,14 +210,9 @@ const dashedBorderPlugin: ReturnType<typeof plugin> = plugin(
}, },
".dashed-border": { ".dashed-border": {
position: "relative", position: "relative",
// "--tw-dashed-border-gap": DEFAULT_GAP,
// "--tw-dashed-border-length": DEFAULT_LENGTH,
// "--tw-dashed-border-offset": DEFAULT_OFFSET,
// "--tw-dashed-border-width": DEFAULT_WIDTH,
"--tw-dashed-border-color": DEFAULT_COLOR, "--tw-dashed-border-color": DEFAULT_COLOR,
"--tw-dashed-border-ease": "var(--tw-ease)", "--tw-dashed-border-ease": "var(--tw-ease)",
"--tw-dashed-border-duration": "var(--tw-duration)", "--tw-dashed-border-duration": "var(--tw-duration)",
// "--tw-dashed-border-radius": DEFAULT_RADIUS,
}, },
".dashed-border-svg": { ".dashed-border-svg": {
position: "absolute", position: "absolute",
@@ -256,17 +245,6 @@ const dashedBorderPlugin: ReturnType<typeof plugin> = plugin(
height: INNER_SIZE, height: INNER_SIZE,
rx: ADJUSTED_RADIUS, rx: ADJUSTED_RADIUS,
}, },
".dashed-border-bottom-line": {
fill: "none",
stroke: "var(--tw-dashed-border-color)",
"stroke-width": "var(--tw-dashed-border-width)",
"stroke-dasharray":
"var(--tw-dashed-border-length) var(--tw-dashed-border-gap)",
"stroke-dashoffset": "var(--tw-dashed-border-offset)",
// "stroke-linecap": "butt",
"vector-effect": "non-scaling-stroke",
transform: `translateY(${INSET})`,
},
}); });
const spacingRawValues = theme("spacing") as const spacingRawValues = theme("spacing") as
@@ -350,27 +328,17 @@ const dashedBorderPlugin: ReturnType<typeof plugin> = plugin(
} }
const ALL_TRANSITION_PROPERTIES = const ALL_TRANSITION_PROPERTIES =
"stroke-dasharray, stroke-dashoffset, stroke-width, stroke, rx, x, y, width, height"; "stroke-dasharray, stroke-dashoffset, stroke-width, stroke, rx, x, y, width, height";
const ALL_LINE_TRANSITION_PROPERTIES =
"stroke-dasharray, stroke-dashoffset, stroke-width, stroke, transform";
addUtilities({ addUtilities({
".dashed-border.transition .dashed-border-rect": createTransitionStyles( ".dashed-border.transition .dashed-border-rect": createTransitionStyles(
ALL_TRANSITION_PROPERTIES, ALL_TRANSITION_PROPERTIES,
), ),
".dashed-border.transition .dashed-border-bottom-line":
createTransitionStyles(ALL_LINE_TRANSITION_PROPERTIES),
".dashed-border.transition-all .dashed-border-rect": ".dashed-border.transition-all .dashed-border-rect":
createTransitionStyles(ALL_TRANSITION_PROPERTIES), createTransitionStyles(ALL_TRANSITION_PROPERTIES),
".dashed-border.transition-all .dashed-border-bottom-line":
createTransitionStyles(ALL_LINE_TRANSITION_PROPERTIES),
".dashed-border.transition-colors .dashed-border-rect": ".dashed-border.transition-colors .dashed-border-rect":
createTransitionStyles("stroke"), createTransitionStyles("stroke"),
".dashed-border.transition-colors .dashed-border-bottom-line":
createTransitionStyles("stroke"),
".dashed-border.transition-dashed-border .dashed-border-rect": ".dashed-border.transition-dashed-border .dashed-border-rect":
createTransitionStyles(ALL_TRANSITION_PROPERTIES), createTransitionStyles(ALL_TRANSITION_PROPERTIES),
".dashed-border.transition-dashed-border .dashed-border-bottom-line":
createTransitionStyles(ALL_LINE_TRANSITION_PROPERTIES),
}); });
}, },
); );
-217
View File
@@ -1,217 +0,0 @@
import plugin from "tailwindcss/plugin";
interface ThemeValueMap {
[key: string]: string | ThemeValueMap;
}
const DEFAULT_SPACING_VALUE = "0.25rem";
const DEFAULT_GAP = `calc(var(--spacing, ${DEFAULT_SPACING_VALUE}) * 2)`;
const DEFAULT_LENGTH = `calc(var(--spacing, ${DEFAULT_SPACING_VALUE}) * 3)`;
const DEFAULT_BORDER_WIDTH = "1px";
const DEFAULT_OFFSET = "0px";
const DEFAULT_COLOR = "currentColor";
const DEFAULT_RADIUS = "0px";
const DEFAULT_ALIGN = "calc(var(--tw-dashed-border-width) / 2)";
const dashedBorderAlign = "var(--tw-dashed-border-align)";
const svgRadius = `max(0px, calc(var(--tw-dashed-border-radius) - (${dashedBorderAlign})))`;
const dashedBorderTransitionProperties =
"stroke-dasharray, stroke-dashoffset, stroke-width, stroke, rx, x, y, width, height";
const flattenThemeMap = (
input: ThemeValueMap,
path: string[] = [],
): Record<string, string> => {
const output: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
const nextPath = key === "DEFAULT" ? path : [...path, key];
if (typeof value === "string") {
output[nextPath.join("-")] = value;
continue;
}
Object.assign(output, flattenThemeMap(value, nextPath));
}
return output;
};
const roundedSelector = (name: string): string =>
name === "DEFAULT" ? ".rounded" : `.rounded-${name}`;
const resolveAlignValue = (value: string): string =>
/^-?(?:\d+|\d*\.\d+)$/.test(value)
? `calc((0.5 - ${value}) * var(--tw-dashed-border-width))`
: `calc((var(--tw-dashed-border-width) / 2) - (${value}))`;
const dashedBorderPlugin: ReturnType<typeof plugin> = plugin(
({ addBase, addUtilities, matchUtilities, theme }) => {
addBase({
"@property --tw-dashed-border-gap": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": "0.5rem",
},
"@property --tw-dashed-border-length": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": "0.75rem",
},
"@property --tw-dashed-border-offset": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": DEFAULT_OFFSET,
},
"@property --tw-dashed-border-width": {
syntax: "<length>",
inherits: "true",
"initial-value": DEFAULT_BORDER_WIDTH,
},
"@property --tw-dashed-border-align": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": DEFAULT_ALIGN,
},
"@property --tw-dashed-border-radius": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": DEFAULT_RADIUS,
},
});
addUtilities({
".dashed-border": {
"--tw-dashed-border-gap": DEFAULT_GAP,
"--tw-dashed-border-length": DEFAULT_LENGTH,
"--tw-dashed-border-offset": DEFAULT_OFFSET,
"--tw-dashed-border-width": DEFAULT_BORDER_WIDTH,
"--tw-dashed-border-color": DEFAULT_COLOR,
"--tw-dashed-border-align": DEFAULT_ALIGN,
"--tw-dashed-border-radius": DEFAULT_RADIUS,
position: "relative",
},
".dashed-border-svg": {
display: "block",
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
pointerEvents: "none",
},
".dashed-border-rect": {
fill: "none",
stroke: "var(--tw-dashed-border-color)",
strokeWidth: "var(--tw-dashed-border-width)",
strokeDasharray:
"var(--tw-dashed-border-length) var(--tw-dashed-border-gap)",
strokeDashoffset: "var(--tw-dashed-border-offset)",
shapeRendering: "geometricPrecision",
vectorEffect: "non-scaling-stroke",
x: dashedBorderAlign,
y: dashedBorderAlign,
width: `calc(100% - (${dashedBorderAlign} * 2))`,
height: `calc(100% - (${dashedBorderAlign} * 2))`,
rx: svgRadius,
},
".dashed-border-transition, .dashed-border.transition, .dashed-border.transition-all":
{
transitionTimingFunction:
"var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1))",
transitionDuration: "var(--default-transition-duration, 150ms)",
},
".dashed-border.transition-colors": {
transitionTimingFunction:
"var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1))",
transitionDuration: "var(--default-transition-duration, 150ms)",
},
".dashed-border-transition .dashed-border-rect, .dashed-border.transition .dashed-border-rect, .dashed-border.transition-all .dashed-border-rect":
{
transitionProperty: dashedBorderTransitionProperties,
transitionTimingFunction: "inherit",
transitionDuration: "inherit",
},
".dashed-border.transition-colors .dashed-border-rect": {
transitionProperty: "stroke",
transitionTimingFunction: "inherit",
transitionDuration: "inherit",
},
});
const borderWidths = theme("borderWidth", {
DEFAULT: DEFAULT_BORDER_WIDTH,
}) as Record<string, string>;
const colors = flattenThemeMap(theme("colors", {}) as ThemeValueMap);
const radii = theme("borderRadius", {
DEFAULT: DEFAULT_RADIUS,
}) as Record<string, string>;
matchUtilities(
{
"dashed-border-gap": (value: string) => ({
"--tw-dashed-border-gap": value,
}),
"dashed-border-length": (value: string) => ({
"--tw-dashed-border-length": value,
}),
"dashed-border-offset": (value: string) => ({
"--tw-dashed-border-offset": value,
}),
"dashed-border-align": (value: string) => ({
"--tw-dashed-border-align": resolveAlignValue(value),
}),
"dashed-border-radius": (value: string) => ({
"--tw-dashed-border-radius": value,
}),
},
{
values: {
inside: DEFAULT_ALIGN,
edge: "0px",
outside: "calc(var(--tw-dashed-border-width) / -2)",
},
},
);
matchUtilities(
{
"dashed-border-width": (value: string) => ({
"--tw-dashed-border-width": value,
}),
},
{
values: borderWidths,
},
);
matchUtilities(
{
"dashed-border-color": (value: string) => ({
"--tw-dashed-border-color": value,
}),
},
{
values: {
...colors,
current: "currentColor",
inherit: "inherit",
transparent: "transparent",
},
},
);
addUtilities(
Object.fromEntries(
Object.entries(radii).map(([name, value]) => [
`.dashed-border${roundedSelector(name)}`,
{
"--tw-dashed-border-radius": value,
},
]),
),
);
},
);
export default dashedBorderPlugin;
-210
View File
@@ -1,210 +0,0 @@
import plugin from "tailwindcss/plugin";
interface ThemeValueMap {
[key: string]: string | ThemeValueMap;
}
const DEFAULT_SPACING_VALUE = "0.25rem";
const DEFAULT_GAP = `calc(var(--spacing, ${DEFAULT_SPACING_VALUE}) * 2)`;
const DEFAULT_LENGTH = `calc(var(--spacing, ${DEFAULT_SPACING_VALUE}) * 3)`;
const DEFAULT_BORDER_WIDTH = "1px";
const DEFAULT_OFFSET = "0px";
const DEFAULT_COLOR = "currentColor";
const DEFAULT_RADIUS = "0px";
const DEFAULT_ALIGNMENT = "0%";
const dashedBorderInset =
"calc((0.5 - (var(--tw-dashed-border-alignment) / 100%)) * var(--tw-dashed-border-width))";
const svgRadius = `max(0px, calc(var(--tw-dashed-border-radius) - (${dashedBorderInset})))`;
const dashedBorderTransitionProperties =
"stroke-dasharray, stroke-dashoffset, stroke-width, stroke, rx, x, y, width, height";
const flattenThemeMap = (
input: ThemeValueMap,
path: string[] = [],
): Record<string, string> => {
const output: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
const nextPath = key === "DEFAULT" ? path : [...path, key];
if (typeof value === "string") {
output[nextPath.join("-")] = value;
continue;
}
Object.assign(output, flattenThemeMap(value, nextPath));
}
return output;
};
const roundedSelector = (name: string): string =>
name === "DEFAULT" ? ".rounded" : `.rounded-${name}`;
const dashedBorderPlugin: ReturnType<typeof plugin> = plugin(
({ addBase, addUtilities, matchUtilities, theme }) => {
addBase({
"@property --tw-dashed-border-gap": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": "0.5rem",
},
"@property --tw-dashed-border-length": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": "0.75rem",
},
"@property --tw-dashed-border-offset": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": DEFAULT_OFFSET,
},
"@property --tw-dashed-border-width": {
syntax: "<length>",
inherits: "true",
"initial-value": DEFAULT_BORDER_WIDTH,
},
"@property --tw-dashed-border-alignment": {
syntax: "<percentage>",
inherits: "true",
"initial-value": DEFAULT_ALIGNMENT,
},
"@property --tw-dashed-border-radius": {
syntax: "<length-percentage>",
inherits: "true",
"initial-value": DEFAULT_RADIUS,
},
});
addUtilities({
".dashed-border": {
"--tw-dashed-border-gap": DEFAULT_GAP,
"--tw-dashed-border-length": DEFAULT_LENGTH,
"--tw-dashed-border-offset": DEFAULT_OFFSET,
"--tw-dashed-border-width": DEFAULT_BORDER_WIDTH,
"--tw-dashed-border-color": DEFAULT_COLOR,
"--tw-dashed-border-alignment": DEFAULT_ALIGNMENT,
"--tw-dashed-border-radius": DEFAULT_RADIUS,
position: "relative",
},
".dashed-border-svg": {
display: "block",
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
overflow: "hidden",
pointerEvents: "none",
},
".dashed-border-rect": {
fill: "none",
stroke: "var(--tw-dashed-border-color)",
strokeWidth: "var(--tw-dashed-border-width)",
strokeDasharray:
"var(--tw-dashed-border-length) var(--tw-dashed-border-gap)",
strokeDashoffset: "var(--tw-dashed-border-offset)",
shapeRendering: "geometricPrecision",
vectorEffect: "non-scaling-stroke",
x: dashedBorderInset,
y: dashedBorderInset,
width: `calc(100% - (${dashedBorderInset} * 2))`,
height: `calc(100% - (${dashedBorderInset} * 2))`,
rx: svgRadius,
},
".dashed-border-transition, .dashed-border.transition, .dashed-border.transition-all":
{
transitionTimingFunction:
"var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1))",
transitionDuration: "var(--default-transition-duration, 150ms)",
},
".dashed-border.transition-colors": {
transitionTimingFunction:
"var(--default-transition-timing-function, cubic-bezier(0.4, 0, 0.2, 1))",
transitionDuration: "var(--default-transition-duration, 150ms)",
},
".dashed-border-transition .dashed-border-rect, .dashed-border.transition .dashed-border-rect, .dashed-border.transition-all .dashed-border-rect":
{
transitionProperty: dashedBorderTransitionProperties,
transitionTimingFunction: "inherit",
transitionDuration: "inherit",
},
".dashed-border.transition-colors .dashed-border-rect": {
transitionProperty: "stroke",
transitionTimingFunction: "inherit",
transitionDuration: "inherit",
},
});
const spacing = theme("spacing", {}) as Record<string, string>;
const borderWidths = theme("borderWidth", {
DEFAULT: DEFAULT_BORDER_WIDTH,
}) as Record<string, string>;
const colors = flattenThemeMap(theme("colors", {}) as ThemeValueMap);
const radii = theme("borderRadius", {
DEFAULT: DEFAULT_RADIUS,
}) as Record<string, string>;
matchUtilities(
{
"dashed-border-gap": (value: string) => ({
"--tw-dashed-border-gap": value,
}),
"dashed-border-length": (value: string) => ({
"--tw-dashed-border-length": value,
}),
"dashed-border-offset": (value: string) => ({
"--tw-dashed-border-offset": value,
}),
"dashed-border-align": (value: string) => ({
"--tw-dashed-border-alignment": value,
}),
"dashed-border-radius": (value: string) => ({
"--tw-dashed-border-radius": value,
}),
},
{
values: spacing,
},
);
matchUtilities(
{
"dashed-border-width": (value: string) => ({
"--tw-dashed-border-width": value,
}),
},
{
values: borderWidths,
},
);
matchUtilities(
{
"dashed-border-color": (value: string) => ({
"--tw-dashed-border-color": value,
}),
},
{
values: {
...colors,
current: "currentColor",
inherit: "inherit",
transparent: "transparent",
},
},
);
addUtilities(
Object.fromEntries(
Object.entries(radii).map(([name, value]) => [
`.dashed-border${roundedSelector(name)}`,
{
"--tw-dashed-border-radius": value,
},
]),
),
);
},
);
export default dashedBorderPlugin;
-33
View File
@@ -1,33 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { createRequire } from 'node:module'
import plugin from 'tailwindcss/plugin'
import { compile } from 'tailwindcss'
import flattenColorPalette from 'tailwindcss/lib/util/flattenColorPalette'
const require = createRequire(import.meta.url)
const pluginTest = plugin(({ theme, config }) => {
const raw = theme('colors')
const flat = flattenColorPalette(raw ?? {})
const random_fn = theme("backgroundOpacity.__BARE_VALUE__")
const random_fn = theme("backgroundOpacity.__BARE_VALUE__")
console.log('default bare function at 80', random_fn, random_fn({value: 80, fraction: null}));
})
await compile('@import "tailwindcss"; @plugin "inspect-plugin";', {
base: process.cwd(),
async loadModule(id, base) {
if (id === 'inspect-plugin') {
return { path: 'inspect-plugin', base, module: pluginTest }
}
},
async loadStylesheet(id, base) {
if (id === 'tailwindcss') {
const pkg = path.dirname(require.resolve('tailwindcss/package.json'))
const file = path.join(pkg, 'index.css')
return { path: file, base: path.dirname(file), content: await fs.readFile(file, 'utf8') }
}
const resolved = path.resolve(base, id)
return { path: resolved, base: path.dirname(resolved), content: await fs.readFile(resolved, 'utf8') }
},
})