Skip to content

Commit 10255f4

Browse files
committed
Extract rule: template-no-outlet-outside-routes
1 parent 0988ad1 commit 10255f4

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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 (templates/components/) are not routes.
9+
* - Co-located component templates (app/components/) are not routes.
10+
*/
11+
function isRouteTemplate(filePath) {
12+
if (typeof filePath !== 'string') {
13+
return true; // unknown — assume it could be a route
14+
}
15+
16+
// GJS/GTS files are always components, never route templates
17+
if (filePath.endsWith('.gjs') || filePath.endsWith('.gts')) {
18+
return false;
19+
}
20+
21+
const baseName = path.basename(filePath);
22+
23+
// Partials start with '-'
24+
if (baseName.startsWith('-')) {
25+
return false;
26+
}
27+
28+
const normalized = filePath.replaceAll('\\', '/');
29+
30+
// Classic component template: <app>/templates/components/...
31+
if (
32+
/\/templates\/components\//.test(normalized) ||
33+
/^[^/]+\/templates\/components\//.test(normalized)
34+
) {
35+
return false;
36+
}
37+
38+
// Co-located component: <app>/components/...
39+
if (/\/components\//.test(normalized) && !/\/templates\//.test(normalized)) {
40+
return false;
41+
}
42+
43+
return true;
44+
}
45+
46+
/** @type {import('eslint').Rule.RuleModule} */
47+
module.exports = {
48+
meta: {
49+
type: 'problem',
50+
docs: {
51+
description: 'disallow {{outlet}} outside of route templates',
52+
category: 'Best Practices',
53+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-outlet-outside-routes.md',
54+
templateMode: 'both',
55+
},
56+
schema: [],
57+
messages: {
58+
noOutletOutsideRoutes: 'outlet should only be used in route templates.',
59+
},
60+
originallyFrom: {
61+
name: 'ember-template-lint',
62+
rule: 'lib/rules/no-outlet-outside-routes.js',
63+
docs: 'docs/rule/no-outlet-outside-routes.md',
64+
tests: 'test/unit/rules/no-outlet-outside-routes-test.js',
65+
},
66+
},
67+
68+
create(context) {
69+
const filename = context.filename || context.getFilename();
70+
71+
// In GJS/GTS (strict) mode, templates are always in components — never route templates.
72+
// Only .hbs files can be route templates, so apply the filePath heuristic only for those.
73+
const routeTemplate = filename.endsWith('.hbs') ? isRouteTemplate(filename) : false;
74+
75+
return {
76+
GlimmerMustacheStatement(node) {
77+
if (node.path.type === 'GlimmerPathExpression' && node.path.original === 'outlet') {
78+
if (!routeTemplate) {
79+
context.report({
80+
node,
81+
messageId: 'noOutletOutsideRoutes',
82+
});
83+
}
84+
}
85+
},
86+
};
87+
},
88+
};
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
],
20+
invalid: [
21+
// GJS files are always components — outlet should be flagged
22+
{
23+
code: '<template>{{outlet}}</template>',
24+
output: null,
25+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
26+
},
27+
{
28+
code: '<template><div>{{outlet}}</div></template>',
29+
output: null,
30+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
31+
},
32+
// Co-located component (explicit filename)
33+
{
34+
filename: 'app/components/my-component.gjs',
35+
code: '<template>{{outlet}}</template>',
36+
output: null,
37+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
38+
},
39+
],
40+
});
41+
42+
const hbsRuleTester = new RuleTester({
43+
parser: require.resolve('ember-eslint-parser/hbs'),
44+
parserOptions: {
45+
ecmaVersion: 2022,
46+
sourceType: 'module',
47+
},
48+
});
49+
50+
hbsRuleTester.run('template-no-outlet-outside-routes', rule, {
51+
valid: [
52+
// Non-outlet usage
53+
'{{foo}}',
54+
'{{button}}',
55+
// Block form is ambiguous (could be a component named "outlet")
56+
'{{#outlet}}Why?!{{/outlet}}',
57+
'{{#outlet}}Works because ambiguous{{/outlet}}',
58+
// Route templates — outlet is allowed
59+
{
60+
filename: 'app/templates/foo/route.hbs',
61+
code: '{{outlet}}',
62+
},
63+
{
64+
filename: 'app/templates/routes/foo.hbs',
65+
code: '{{outlet}}',
66+
},
67+
// Ambiguous path — not clearly a component, so allowed
68+
{
69+
filename: 'app/templates/something/foo.hbs',
70+
code: '{{outlet}}',
71+
},
72+
// "components" in the prefix but not under templates/components/ or app/components/
73+
{
74+
filename: 'components/templates/application.hbs',
75+
code: '{{outlet}}',
76+
},
77+
],
78+
invalid: [
79+
// Classic component template
80+
{
81+
filename: 'app/templates/components/foo/layout.hbs',
82+
code: '{{outlet}}',
83+
output: null,
84+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
85+
},
86+
// Partial (basename starts with '-')
87+
{
88+
filename: 'app/templates/foo/-mything.hbs',
89+
code: '{{outlet}}',
90+
output: null,
91+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
92+
},
93+
// Co-located component template
94+
{
95+
filename: 'app/components/foo/layout.hbs',
96+
code: '{{outlet}}',
97+
output: null,
98+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
99+
},
100+
// Nested outlet in component
101+
{
102+
filename: 'app/components/foo/layout.hbs',
103+
code: '<div>{{outlet}}</div>',
104+
output: null,
105+
errors: [{ messageId: 'noOutletOutsideRoutes' }],
106+
},
107+
],
108+
});

0 commit comments

Comments
 (0)