Skip to content

Commit bd32b5d

Browse files
Merge pull request #2490 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-outlet-outside-routes
Extract rule: template-no-outlet-outside-routes
2 parents 0988ad1 + e5dc232 commit bd32b5d

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ rules in templates can be disabled with eslint directives with mustache or html
208208
| [template-no-input-placeholder](docs/rules/template-no-input-placeholder.md) | disallow placeholder attribute on input elements | | | |
209209
| [template-no-input-tagname](docs/rules/template-no-input-tagname.md) | disallow tagName attribute on {{input}} helper | | | |
210210
| [template-no-log](docs/rules/template-no-log.md) | disallow {{log}} in templates | | | |
211+
| [template-no-outlet-outside-routes](docs/rules/template-no-outlet-outside-routes.md) | disallow {{outlet}} outside of route templates | | | |
211212
| [template-no-page-title-component](docs/rules/template-no-page-title-component.md) | disallow usage of ember-page-title component | | | |
212213

213214
### Components
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# ember/template-no-outlet-outside-routes
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow `{{outlet}}` outside of route templates. The `outlet` helper should only be used in route templates to render nested routes.
6+
7+
## Rule Details
8+
9+
This rule prevents the use of `{{outlet}}` in component templates where it doesn't make sense.
10+
11+
## Examples
12+
13+
Examples of **incorrect** code for this rule:
14+
15+
```gjs
16+
// In a component template
17+
<template>
18+
<div>
19+
{{outlet}}
20+
</div>
21+
</template>
22+
```
23+
24+
Examples of **correct** code for this rule:
25+
26+
```gjs
27+
// In a component template
28+
<template>
29+
<div>Content</div>
30+
</template>
31+
```
32+
33+
## References
34+
35+
- [Ember guides/routing](https://guides.emberjs.com/release/routing/rendering-a-template/)
36+
- [Ember api/outlet helper](https://api.emberjs.com/ember/release/classes/Ember.Templates.helpers/methods/outlet?anchor=outlet)
37+
- [ember-template-lint no-outlet-outside-routes](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-outlet-outside-routes.md)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
const path = require('path');
2+
3+
/**
4+
* Determine whether a file path corresponds to a route template.
5+
* Mirrors ember-template-lint's is-route-template.js heuristic:
6+
* - If the path is unknown, assume it could be a route (don't report).
7+
* - Partials (basename starts with '-') are not routes.
8+
* - Classic component templates (<app>/templates/components/) are not routes.
9+
* - Co-located component templates (<app>/components/) are not routes.
10+
*
11+
* Note: GJS/GTS files can be route templates (e.g. app/routes/foo.gjs).
12+
*/
13+
function isRouteTemplate(filePath) {
14+
if (typeof filePath !== 'string') {
15+
return true; // unknown — assume it could be a route
16+
}
17+
18+
const normalized = filePath.replaceAll('\\', '/');
19+
const baseName = path.basename(normalized);
20+
21+
if (baseName.startsWith('-')) {
22+
return false;
23+
}
24+
25+
return (
26+
!/^[^/]+\/templates\/components\//.test(normalized) && // classic component
27+
!/^[^/]+\/components\//.test(normalized) // co-located component template
28+
);
29+
}
30+
31+
/** @type {import('eslint').Rule.RuleModule} */
32+
module.exports = {
33+
meta: {
34+
type: 'problem',
35+
docs: {
36+
description: 'disallow {{outlet}} outside of route templates',
37+
category: 'Best Practices',
38+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-outlet-outside-routes.md',
39+
templateMode: 'both',
40+
},
41+
schema: [],
42+
messages: {
43+
noOutletOutsideRoutes: 'outlet should only be used in route templates.',
44+
},
45+
originallyFrom: {
46+
name: 'ember-template-lint',
47+
rule: 'lib/rules/no-outlet-outside-routes.js',
48+
docs: 'docs/rule/no-outlet-outside-routes.md',
49+
tests: 'test/unit/rules/no-outlet-outside-routes-test.js',
50+
},
51+
},
52+
53+
create(context) {
54+
const filename = context.filename || context.getFilename();
55+
56+
const routeTemplate = isRouteTemplate(filename);
57+
58+
return {
59+
GlimmerMustacheStatement(node) {
60+
if (node.path.type === 'GlimmerPathExpression' && node.path.original === 'outlet') {
61+
if (!routeTemplate) {
62+
context.report({
63+
node,
64+
messageId: 'noOutletOutsideRoutes',
65+
});
66+
}
67+
}
68+
},
69+
};
70+
},
71+
};
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//------------------------------------------------------------------------------
2+
// Requirements
3+
//------------------------------------------------------------------------------
4+
5+
const rule = require('../../../lib/rules/template-no-outlet-outside-routes');
6+
const RuleTester = require('eslint').RuleTester;
7+
8+
const ruleTester = new RuleTester({
9+
parser: require.resolve('ember-eslint-parser'),
10+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
11+
});
12+
13+
ruleTester.run('template-no-outlet-outside-routes', rule, {
14+
valid: [
15+
// Non-outlet usage in components
16+
'<template><div>Content</div></template>',
17+
'<template>{{foo}}</template>',
18+
'<template>{{button}}</template>',
19+
// GJS route templates — outlet is allowed
20+
{
21+
filename: 'app/routes/foo.gjs',
22+
code: '<template>{{outlet}}</template>',
23+
},
24+
{
25+
filename: 'app/routes/foo.gts',
26+
code: '<template>{{outlet}}</template>',
27+
},
28+
],
29+
invalid: [
30+
// Co-located component (explicit filename)
31+
{
32+
filename: 'app/components/my-component.gjs',
33+
code: '<template>{{outlet}}</template>',
34+
output: null,
35+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
36+
},
37+
{
38+
filename: 'app/components/my-component.gts',
39+
code: '<template>{{outlet}}</template>',
40+
output: null,
41+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
42+
},
43+
{
44+
filename: 'app/components/my-component.gjs',
45+
code: '<template><div>{{outlet}}</div></template>',
46+
output: null,
47+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
48+
},
49+
],
50+
});
51+
52+
const hbsRuleTester = new RuleTester({
53+
parser: require.resolve('ember-eslint-parser/hbs'),
54+
parserOptions: {
55+
ecmaVersion: 2022,
56+
sourceType: 'module',
57+
},
58+
});
59+
60+
hbsRuleTester.run('template-no-outlet-outside-routes', rule, {
61+
valid: [
62+
// Non-outlet usage
63+
'{{foo}}',
64+
'{{button}}',
65+
// Route templates — outlet is allowed
66+
{
67+
filename: 'app/templates/foo/route.hbs',
68+
code: '{{outlet}}',
69+
},
70+
{
71+
filename: 'app/templates/routes/foo.hbs',
72+
code: '{{outlet}}',
73+
},
74+
// Block form in route templates
75+
{
76+
filename: 'app/templates/foo/route.hbs',
77+
code: '{{#outlet}}Why?!{{/outlet}}',
78+
},
79+
{
80+
filename: 'app/templates/routes/foo.hbs',
81+
code: '{{#outlet}}Why?!{{/outlet}}',
82+
},
83+
// Ambiguous path — not clearly a component, so allowed
84+
{
85+
filename: 'app/templates/something/foo.hbs',
86+
code: '{{#outlet}}Works because ambiguous{{/outlet}}',
87+
},
88+
// "components" appears in prefix but not under <app>/templates/components/ or <app>/components/
89+
{
90+
filename: 'components/templates/application.hbs',
91+
code: '{{outlet}}',
92+
},
93+
],
94+
invalid: [
95+
// Classic component template
96+
{
97+
filename: 'app/templates/components/foo/layout.hbs',
98+
code: '{{outlet}}',
99+
output: null,
100+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
101+
},
102+
// Partial (basename starts with '-')
103+
{
104+
filename: 'app/templates/foo/-mything.hbs',
105+
code: '{{outlet}}',
106+
output: null,
107+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
108+
},
109+
// Co-located component template
110+
{
111+
filename: 'app/components/foo/layout.hbs',
112+
code: '{{outlet}}',
113+
output: null,
114+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
115+
},
116+
// Nested outlet in component
117+
{
118+
filename: 'app/components/foo/layout.hbs',
119+
code: '<div>{{outlet}}</div>',
120+
output: null,
121+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
122+
},
123+
],
124+
});

0 commit comments

Comments
 (0)