Skip to content

Commit

Permalink
feat(preprocess-auto-slug): mature implementation and customization
Browse files Browse the repository at this point in the history
  • Loading branch information
Quang Phan authored and vnphanquang committed Nov 7, 2022
1 parent 6d31c89 commit cb2cd7f
Show file tree
Hide file tree
Showing 10 changed files with 245 additions and 77 deletions.
5 changes: 5 additions & 0 deletions .changeset/little-bobcats-rescue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@svelte-put/preprocess-auto-slug': minor
---

more mature options and ability to customization
9 changes: 6 additions & 3 deletions apps/docs/src/routes/docs/(pkg)/clickoutside/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,17 @@

<h2>Typescript Support</h2>

Ambient types for custom events should be available automatically where `clickoutside` is imported.
<p>
Ambient types for custom events should be available automatically where `clickoutside` is
imported.
</p>

<Code
lang="svelte"
code="./codes/typescript-auto-example.svelte?raw"
title="component-using-clickoutside.svelte"
title="using-clickoutside.svelte"
/>

If the above is not working, fall back to this:
<p>If the above is not working, fall back to this:</p>

<Code lang={typescript} code="./codes/typescript-fallback.d.ts?raw" title="src/app.d.ts" />
11 changes: 10 additions & 1 deletion apps/docs/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ const config = {
},
],
}),
autoSlug(),
autoSlug((defaultOptions) => ({
anchor: {
content: '#',
position: 'prepend',
properties: {
...defaultOptions.anchor.properties,
class: 'heading-anchor',
},
},
})),
preprocess({ postcss: true }),
],

Expand Down
31 changes: 17 additions & 14 deletions apps/docs/tailwind.config.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
const plugin = require('tailwindcss/plugin');

const sveltePut = plugin(
({ addComponents, addUtilities }) => {
({ addComponents, addUtilities, addBase }) => {
addBase({
'h1,h2,h3,h4,h5,h6': {
'@apply relative': {},
'& .heading-anchor': {
'@apply text-primary absolute top-0 bottom-0 right-full opacity-0 transition-opacity duration-150 mr-1':
{},
'text-decoration': 'none',
'font-weight': 'inherit',
},
'&:hover .heading-anchor': {
'@apply opacity-100': {},
},
},
});

addUtilities({
'.bg-gradient-brand': {
'@apply bg-gradient-to-r from-svelte to-[#42b883]': {},
Expand Down Expand Up @@ -41,20 +56,8 @@ const sveltePut = plugin(
h1: {
'font-size': '2rem',
},
'h2,h3,h4,h5,h6': {
position: 'relative',
'&:hover::before': {
content: '"#"',
position: 'absolute',
right: '101%',
},
},
'h1,h2,h3,h4,h5,h6': {
'scroll-margin-top': theme('spacing.header'),
'& a': {
'text-decoration': 'none',
'font-weight': 'inherit',
},
},
},
},
Expand Down Expand Up @@ -114,7 +117,7 @@ const sveltePut = plugin(

/** @type {import("tailwindcss").Config } */
const config = {
content: ['./src/**/*.{html,js,svelte,ts,md}'],
content: ['./src/**/*.{html,js,svelte,ts,md}', 'svelte.config.js'],
plugins: [sveltePut, require('@tailwindcss/typography')],
};

Expand Down
2 changes: 1 addition & 1 deletion packages/preprocessors/auto-slug/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "Generate slug from text content, to add id to headings for example",
"main": "lib/index.js",
"module": "lib/index.mjs",
"types": "lib/src/index.d.ts",
"types": "lib/index.d.ts",
"exports": {
".": {
"import": "./lib/index.mjs",
Expand Down
22 changes: 22 additions & 0 deletions packages/preprocessors/auto-slug/src/auto-slug.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AutoSlugOptions } from './auto-slug.types';

/**
* default options for auto-slug
*
* @public
*/
export const DEFAULT_AUTO_SLUG_OPTIONS: AutoSlugOptions = {
tags: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
attributeName: 'id',
slug: ({ generated }) => generated,
anchor: {
enabled: true,
position: 'prepend',
content: '#',
properties: {
'aria-hidden': 'true',
'tab-index': '-1',
},
href: (slug) => `#${slug}`,
},
};
126 changes: 70 additions & 56 deletions packages/preprocessors/auto-slug/src/auto-slug.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,12 @@
import type { BaseNode } from 'estree';
import Slugger from 'github-slugger';
import BananaSlug from 'github-slugger';
import MagicString from 'magic-string';
import { parse } from 'svelte-parse-markup';
import { walk } from 'svelte/compiler';
import { PreprocessorGroup } from 'svelte/types/compiler/preprocess';

const tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];

/**
* @internal
*/
interface Node extends BaseNode {
name: string;
start: number;
attributes: Array<{ name: string; type: string }>;
children?: Array<Node>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any;
}

/**
* @internal
*/
function hasIdAttribute(node: Node) {
return node.attributes.some((attr) => attr.name === 'id' && attr.type === 'Attribute');
}

/**
* @internal
*/
function getTextContent(node: Node) {
if (node.type === 'Text') return node.data;
if (!node.children || node.children.length === 0) return '';
let text = '';
for (const child of Array.from(node.children)) {
walk(child, {
enter(childNode) {
text += getTextContent(childNode as Node);
},
});
}
return text;
}

/**
* @internal
*/
function isMustacheNode(node: Node) {
return node.children.some((child) => child.type === 'MustacheTag');
}
import { DEFAULT_AUTO_SLUG_OPTIONS } from './auto-slug.constants';
import type { AutoSlugInput, Node } from './auto-slug.types';
import { getTextContent, hasIdAttribute, isMustacheNode } from './auto-slug.utils';

/**
* create svelte preprocessor to generate slug from text content of matching tags
Expand All @@ -59,27 +17,84 @@ function isMustacheNode(node: Node) {
*
* @returns {PreprocessorGroup} svelte preprocessor interface
*/
export function autoSlug(): PreprocessorGroup {
const preprocess = {
export function autoSlug(input: AutoSlugInput): PreprocessorGroup {
let options = DEFAULT_AUTO_SLUG_OPTIONS;
const iOptions = typeof input === 'function' ? input(options) : input;
options = {
...options,
...iOptions,
anchor: {
...options.anchor,
...(iOptions.anchor ?? {}),
},
};
return {
markup({ content, filename }) {
const s = new MagicString(content);
const ast = parse(content, { filename });
const slugs = new Slugger();
const slugger = new BananaSlug();

walk(ast.html, {
enter(node) {
const tNode = node as Node;
if (
node.type === 'Element' &&
tags.includes(tNode.name) &&
!hasIdAttribute(tNode) &&
options.tags.includes(tNode.name) &&
!hasIdAttribute(tNode, options.attributeName) &&
!isMustacheNode(tNode) &&
tNode.children?.length
) {
const textContent = getTextContent(tNode).trim();
if (textContent) {
const slug = slugs.slug(textContent);
s.appendLeft(tNode.children[0].start - 1, ` id="${slug}"`);
const nodeText = getTextContent(tNode).trim();
if (nodeText) {
const slug = options.slug({
generated: slugger.slug(nodeText),
nodeText,
slugger,
});

const idAttrStr = ` ${options.attributeName}="${slug}"`;
s.appendLeft(tNode.children[0].start - 1, idAttrStr);

if (options.anchor.enabled) {
const inlineProperties = Object.entries(options.anchor.properties)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
const anchorOpening = `<a href="#${slug}" ${inlineProperties}>`;
const anchorClosing = '</a>';

switch (options.anchor.position) {
case 'before':
s.appendLeft(
tNode.start,
`${anchorOpening}${options.anchor.content}${anchorClosing}`,
);
break;
case 'prepend':
s.appendRight(
tNode.children[0].start,
`${anchorOpening}${options.anchor.content}${anchorClosing}`,
);
break;
case 'wrap':
s.appendRight(tNode.children[0].start, anchorOpening).appendLeft(
tNode.children[tNode.children.length - 1].end,
anchorClosing,
);
break;
case 'append':
s.appendLeft(
tNode.children[tNode.children.length - 1].end,
`${anchorOpening}${options.anchor.content}${anchorClosing}`,
);
break;
case 'after':
s.appendRight(
tNode.end,
`${anchorOpening}${options.anchor.content}${anchorClosing}`,
);
break;
}
}
}
}
},
Expand All @@ -91,5 +106,4 @@ export function autoSlug(): PreprocessorGroup {
};
},
};
return preprocess;
}
75 changes: 75 additions & 0 deletions packages/preprocessors/auto-slug/src/auto-slug.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { BaseNode } from 'estree';
import type BananaSlug from 'github-slugger';

/** @internal */
type PartialAutoSlugOptions = Omit<AutoSlugOptions, 'anchor'> & {
anchor?: Partial<AutoSlugOptions['anchor']>;
};

/**
* @public
*/
export type AutoSlugInput =
| PartialAutoSlugOptions
| ((defaultOptions: AutoSlugOptions) => PartialAutoSlugOptions);

/**
* @public
*/
export interface AutoSlugAnchorOptions {
enabled: boolean;
/**
* where to create the anchor tag
*
* - 'prepend' — inject link before the target tag text
* - 'append' — inject link after the target tag text
* - 'wrap' — wrap the whole target tag text with the link
* - 'before' — insert link before the target tag
* - 'after' — insert link after the target tag
*/
position: 'prepend' | 'append' | 'wrap' | 'before' | 'after';
/** default to '#', ignored when behavior is `wrap` */
content: string;
/** defaults to { 'aria-hidden': 'true', 'tab-index': '-1' } */
properties: Record<string, string>;
href: (slug: string) => string;
}

/**
* @public
*/
export interface SlugResolverInput {
/** generated slug, by default slug will resolve to this */
generated: string;
/** text extracted from original node */
nodeText: string;
/** the {@link https://github.com/Flet/github-slugger | BananaSlug} object */
slugger: BananaSlug;
}

/**
* @public
*/
export interface AutoSlugOptions {
/** target tag, default to all heading tags */
tags: string[];
/** default to `id` */
attributeName: string;
/** instructions to add the anchor tag */
anchor: AutoSlugAnchorOptions;
/** slug resolver */
slug: (SlugResolverInput) => string;
}

/**
* @internal
*/
export interface Node extends BaseNode {
name: string;
start: number;
end: number;
attributes: Array<{ name: string; type: string }>;
children?: Array<Node>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any;
}
Loading

0 comments on commit cb2cd7f

Please sign in to comment.