Skip to content

Commit 06f1594

Browse files
authored
Merge pull request #77 from swup/feat/rule.if
feat: `rule.if`
2 parents be153e2 + d973886 commit 06f1594

9 files changed

+95
-50
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,12 @@ Using this option, you can re-enable it for selected visits:
235235

236236
Optional, Type: `boolean | string` – If you have [Accessibility Plugin](https://github.com/swup/a11y-plugin/) installed, you can adjust which element to focus for the visit [as described here](https://github.com/swup/a11y-plugin/#visita11yfocus).
237237

238+
#### rule.if
239+
240+
Optional, Type: `(visit) => boolean` – Provide a predicate function for fine-grained control over the matching behavior of a rule.
241+
242+
A predicate function that allows for fine-grained control over the matching behavior of a rule. This function receives the current [visit](https://swup.js.org/visit/) as a parameter, and must return a boolean value. If the function returns `false`, the rule is being skipped for the current visit, even if it matches the current route.
243+
238244
### debug
239245

240246
Type: `boolean`. Set to `true` for debug information in the console. Defaults to `false`.

src/SwupFragmentPlugin.ts

+30-12
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import type { Options, Rule, Route, FragmentVisit } from './inc/defs.js';
1212
import * as handlers from './inc/handlers.js';
1313
import { __DEV__ } from './inc/env.js';
14+
import type { Visit } from 'swup';
1415

1516
type RequireKeys<T, K extends keyof T> = Partial<T> & Pick<T, K>;
1617
type InitOptions = RequireKeys<Options, 'rules'>;
@@ -84,47 +85,64 @@ export default class SwupFragmentPlugin extends PluginBase {
8485
cleanupFragmentElements();
8586
}
8687

88+
/**
89+
* Set completely new rules
90+
*
91+
* @access public
92+
*/
8793
setRules(rules: Rule[]) {
8894
this._rawRules = cloneRules(rules);
8995
this._parsedRules = rules.map((rule) => this.parseRule(rule));
9096
if (__DEV__) this.logger?.log('Updated fragment rules', this.getRules());
9197
}
9298

99+
/**
100+
* Get a clone of the current rules
101+
*
102+
* @access public
103+
*/
93104
getRules() {
94105
return cloneRules(this._rawRules);
95106
}
96107

108+
/**
109+
* Prepend a rule to the existing rules
110+
*
111+
* @access public
112+
*/
97113
prependRule(rule: Rule) {
98114
this.setRules([rule, ...this.getRules()]);
99115
}
100116

117+
/**
118+
* Append a rule to the existing rules
119+
*
120+
* @access public
121+
*/
101122
appendRule(rule: Rule) {
102123
this.setRules([...this.getRules(), rule]);
103124
}
104125

105126
/**
106-
* Add a fragment rule
107-
* @param {Rule} rule The rule options
108-
* @param {'start' | 'end'} at Should the rule be added to the beginning or end of the existing rules?
127+
* Parse a rule (for e.g. debugging)
128+
*
129+
* @access public
109130
*/
110-
parseRule({ from, to, containers, name, scroll, focus }: Rule): ParsedRule {
131+
parseRule(rule: Rule): ParsedRule {
111132
return new ParsedRule({
112-
from,
113-
to,
114-
containers,
115-
name,
116-
scroll,
117-
focus,
133+
...rule,
118134
logger: this.logger,
119135
swup: this.swup
120136
});
121137
}
122138

123139
/**
124140
* Get the fragment visit object for a given route
141+
*
142+
* @access public
125143
*/
126-
getFragmentVisit(route: Route): FragmentVisit | undefined {
127-
const rule = getFirstMatchingRule(route, this.parsedRules);
144+
getFragmentVisit(route: Route, visit?: Visit): FragmentVisit | undefined {
145+
const rule = getFirstMatchingRule(route, this.parsedRules, visit || this.swup.visit);
128146

129147
// Bail early if no rule matched
130148
if (!rule) return;

src/inc/ParsedRule.ts

+13-10
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import { matchPath, classify, Location } from 'swup';
2-
import type { Swup, Path } from 'swup';
3-
import type { Route } from './defs.js';
2+
import type { Swup, Path, Visit } from 'swup';
3+
import type { Route, Rule, Predicate } from './defs.js';
44
import { dedupe, queryFragmentElement } from './functions.js';
55
import Logger, { highlight } from './Logger.js';
66
import { __DEV__ } from './env.js';
77

8-
type Options = {
8+
type Options = Rule & {
99
swup: Swup;
10-
from: Path;
11-
to: Path;
12-
containers: string[];
13-
name?: string;
14-
scroll?: boolean | string;
15-
focus?: boolean | string;
1610
logger?: Logger;
1711
};
1812

@@ -31,6 +25,7 @@ export default class ParsedRule {
3125
scroll: boolean | string = false;
3226
focus?: boolean | string;
3327
logger?: Logger;
28+
if: Predicate = () => true;
3429

3530
constructor(options: Options) {
3631
this.swup = options.swup;
@@ -41,6 +36,7 @@ export default class ParsedRule {
4136
if (options.name) this.name = classify(options.name);
4237
if (typeof options.scroll !== 'undefined') this.scroll = options.scroll;
4338
if (typeof options.focus !== 'undefined') this.focus = options.focus;
39+
if (typeof options.if === 'function') this.if = options.if;
4440

4541
this.containers = this.parseContainers(options.containers);
4642

@@ -103,7 +99,14 @@ export default class ParsedRule {
10399
/**
104100
* Checks if a given route matches this rule
105101
*/
106-
public matches(route: Route): boolean {
102+
public matches(route: Route, visit: Visit): boolean {
103+
if (!this.if(visit)) {
104+
if (__DEV__) {
105+
this.logger?.log(`ignoring fragment rule due to custom rule.if:`, this);
106+
}
107+
return false;
108+
}
109+
107110
const { url: fromUrl } = Location.fromUrl(route.from);
108111
const { url: toUrl } = Location.fromUrl(route.to);
109112

src/inc/defs.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Path } from 'swup';
1+
import type { Path, Visit } from 'swup';
22

33
/** Represents a route from one to another URL */
44
export type Route = {
@@ -15,6 +15,8 @@ export interface FragmentElement extends HTMLElement {
1515
};
1616
}
1717

18+
export type Predicate = (visit: Visit) => boolean;
19+
1820
/** A fragment rule */
1921
export type Rule = {
2022
from: Path;
@@ -23,6 +25,7 @@ export type Rule = {
2325
name?: string;
2426
scroll?: boolean | string;
2527
focus?: boolean | string;
28+
if?: Predicate;
2629
};
2730

2831
/** The plugin options */

src/inc/functions.ts

+17-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Location } from 'swup';
2-
import type { Swup, Visit, VisitScroll } from 'swup';
1+
import Swup, { Location } from 'swup';
2+
import type { Visit, VisitScroll } from 'swup';
33
import type { default as FragmentPlugin } from '../SwupFragmentPlugin.js';
44
import type { Route, Rule, FragmentVisit, FragmentElement } from './defs.js';
55
import type ParsedRule from './ParsedRule.js';
@@ -230,8 +230,12 @@ export const toggleFragmentVisitClass = (
230230
/**
231231
* Get the first matching rule for a given route
232232
*/
233-
export const getFirstMatchingRule = (route: Route, rules: ParsedRule[]): ParsedRule | undefined => {
234-
return rules.find((rule) => rule.matches(route));
233+
export const getFirstMatchingRule = (
234+
route: Route,
235+
rules: ParsedRule[],
236+
visit: Visit
237+
): ParsedRule | undefined => {
238+
return rules.find((rule) => rule.matches(route, visit));
235239
};
236240

237241
/**
@@ -392,3 +396,12 @@ export function cloneRules(rules: Rule[]): Rule[] {
392396
containers: [...rule.containers]
393397
}));
394398
}
399+
400+
/**
401+
* Create a visit object for tests
402+
*/
403+
export function stubVisit(options: { from?: string; to: string }) {
404+
const swup = new Swup();
405+
// @ts-expect-error swup.createVisit is protected
406+
return swup.createVisit(options);
407+
}

src/inc/handlers.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const onLinkToSelf: Handler<'link:self'> = function (this: FragmentPlugin
2222
const route = getRoute(visit);
2323
if (!route) return;
2424

25-
const rule = getFirstMatchingRule(route, this.parsedRules);
25+
const rule = getFirstMatchingRule(route, this.parsedRules, visit);
2626

2727
if (rule) visit.scroll.reset = false;
2828
};
@@ -34,7 +34,7 @@ export const onVisitStart: Handler<'visit:start'> = async function (this: Fragme
3434
const route = getRoute(visit);
3535
if (!route) return;
3636

37-
const fragmentVisit = this.getFragmentVisit(route);
37+
const fragmentVisit = this.getFragmentVisit(route, visit);
3838

3939
/**
4040
* Bail early if the current route doesn't match

tests/vitest/ParsedRule.test.ts

+15-7
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import ParsedRule from '../../src/inc/ParsedRule.js';
33
import Logger from '../../src/inc/Logger.js';
44
import { spyOnConsole, stubGlobalDocument } from './inc/helpers.js';
55
import Swup from 'swup';
6+
import { stubVisit } from '../../src/inc/functions.js';
67

78
describe('ParsedRule', () => {
89
afterEach(() => {
@@ -93,12 +94,18 @@ describe('ParsedRule', () => {
9394
from: '/users/',
9495
to: '/user/:slug',
9596
containers: ['#fragment-1'],
96-
swup: new Swup()
97+
swup: new Swup(),
98+
if: (visit) => true
9799
});
98-
expect(rule.matches({ from: '/users/', to: '/user/jane' })).toBe(true);
99-
expect(rule.matches({ from: '/users/', to: '/users/' })).toBe(false);
100-
expect(rule.matches({ from: '/user/jane', to: '/users/' })).toBe(false);
101-
expect(rule.matches({ from: '/user/jane', to: '/user/john' })).toBe(false);
100+
const visit = stubVisit({ to: '' });
101+
expect(rule.matches({ from: '/users/', to: '/user/jane' }, visit)).toBe(true);
102+
expect(rule.matches({ from: '/users/', to: '/users/' }, visit)).toBe(false);
103+
expect(rule.matches({ from: '/user/jane', to: '/users/' }, visit)).toBe(false);
104+
expect(rule.matches({ from: '/user/jane', to: '/user/john' }, visit)).toBe(false);
105+
106+
/** Respect rule.if */
107+
rule.if = (visit) => false;
108+
expect(rule.matches({ from: '/users/', to: '/user/jane' }, visit)).toBe(false);
102109
});
103110

104111
it('should validate selectors if matching a rule', () => {
@@ -110,17 +117,18 @@ describe('ParsedRule', () => {
110117
swup: new Swup(),
111118
logger: new Logger()
112119
});
120+
const visit = stubVisit({ to: '' });
113121

114122
/** fragment element missing */
115123
stubGlobalDocument(/*html*/ `<div id="swup" class="transition-main"></div>`);
116-
expect(rule.matches({ from: '/foo/', to: '/bar/' })).toBe(false);
124+
expect(rule.matches({ from: '/foo/', to: '/bar/' }, visit)).toBe(false);
117125
expect(console.error).toBeCalledWith(new Error('skipping rule since #fragment-1 doesn\'t exist in the current document'), expect.any(Object)) // prettier-ignore
118126

119127
/** fragment element outside of swup's default containers */
120128
stubGlobalDocument(
121129
/*html*/ `<div id="swup" class="transition-main"></div><div id="fragment-1"></div>`
122130
);
123-
expect(rule.matches({ from: '/foo/', to: '/bar/' })).toBe(false);
131+
expect(rule.matches({ from: '/foo/', to: '/bar/' }, visit)).toBe(false);
124132
expect(console.error).toBeCalledWith(new Error('skipping rule since #fragment-1 is outside of swup\'s default containers'), expect.any(Object)) // prettier-ignore
125133
});
126134
});

tests/vitest/adjustVisitScroll.test.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { describe, expect, it } from 'vitest';
2-
import { adjustVisitScroll } from '../../src/inc/functions.js';
2+
import { adjustVisitScroll, stubVisit } from '../../src/inc/functions.js';
33
import Swup from 'swup';
44

55
describe('adjustVisitScroll()', () => {
66
it('adjust visit.scroll', () => {
7-
// @ts-expect-error
8-
const { scroll } = new Swup().createVisit({ to: '' });
7+
const { scroll } = stubVisit({ to: '' });
98

109
expect(adjustVisitScroll({ containers: [], scroll: true }, scroll)).toEqual({
1110
reset: true

tests/vitest/getRoute.test.ts

+6-11
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,19 @@
11
import { describe, expect, it } from 'vitest';
2-
import { getRoute } from '../../src/inc/functions.js';
2+
import { getRoute, stubVisit } from '../../src/inc/functions.js';
33
import Swup from 'swup';
44

55
describe('getRoute()', () => {
66
it('should get the route from a visit', () => {
7-
const swup = new Swup();
87
const route = { from: '/page-1/', to: '/page-2/' };
9-
// @ts-expect-error createVisit is protected
10-
const visit = swup.createVisit(route);
8+
const visit = stubVisit(route);
119
expect(getRoute(visit)).toEqual(route);
1210
});
1311

1412
it('should return undefined for incomplete visits', () => {
15-
const swup = new Swup();
16-
// @ts-expect-error createVisit is protected
17-
const withoutTo = swup.createVisit({ to: '' });
18-
expect(getRoute(withoutTo)).toEqual(undefined);
13+
const withEmptyTo = stubVisit({ to: '' });
14+
expect(getRoute(withEmptyTo)).toEqual(undefined);
1915

20-
// @ts-expect-error createVisit is protected
21-
const withoutFrom = swup.createVisit({ from: '', to: '/page-2/' });
22-
expect(getRoute(withoutFrom)).toEqual(undefined);
16+
const withEmptyFrom = stubVisit({ from: '', to: '/page-2/' });
17+
expect(getRoute(withEmptyFrom)).toEqual(undefined);
2318
});
2419
});

0 commit comments

Comments
 (0)