diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bea126..9605a2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.1.0] - 2025-11-15
+### Added
+- Automatically mirror `v-can` guards across `v-else-if` / `v-else` branches and surface the inferred expression in documentation and fixtures.
+- New `chains` playground fixture plus SSR tests that cover guard mirroring and explicit `v-cannot` fallbacks.
+
+### Changed
+- Enforce adjacency rules for implicit `v-cannot` blocks while allowing wrapped fallbacks when the permission expression is provided.
+- Expand README guidance with explicit examples for guard mirroring, wrapped fallbacks, and known DX errors.
+
## [1.0.1] - 2025-11-14
### Changed
- Publish as the scoped package `@eduvia-app/nuxt-can` and document the scoped install steps.
@@ -26,5 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- English README describing usage, playground, and contribution guide.
- Roadmap and release prep guidance.
+[1.1.0]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.1.0
[1.0.1]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.0.1
[1.0.0]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.0.0
diff --git a/README.md b/README.md
index 89f31cb..8ae4619 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ Provide the `__can__` implementation referenced above:
// permissions/can.ts
const permissionsStore = usePermissionsStore()
-export function __can__(path: string[]) {
+export function __can__(...path: string[]) {
return permissionsStore.check(path.join('.'))
}
```
@@ -72,18 +72,94 @@ Now you can write directives that stay type-safe:
…and the compiler rewrites them into plain conditionals:
```vue
-View profile
-Edit profile
-
Access denied
+View profile
+Edit profile
+Access denied
+```
+
+## Directive Patterns
+
+### Guard entire `v-if` / `v-else-if` / `v-else` chains
+
+Once the first branch of a conditional chain carries `v-can`, the transformer automatically mirrors that guard (with the same permission path) on every subsequent `v-else-if` and `v-else`. You can still repeat the directive manually for clarity, but it’s no longer required.
+
+```vue
+
+ Draft state
+
+
+ Pending state
+
+
+ Fallback state
+
+
+ Missing permission
+
+```
+
+Transforms into:
+
+```vue
+
+ Draft state
+
+
+ Pending state
+
+
+ Fallback state
+
+
+ Missing permission
+
+```
+
+### Pass arguments to `v-cannot`
+
+`v-cannot` can mirror the permission expression used by its matching `v-can` by adding the same argument (`v-cannot="can.foo.bar"`). When no argument is specified, the directive must immediately follow the preceding `v-can` block so the transformer can re-use that context.
+
+```vue
+Submit contract
+Contact your admin to unlock submissions.
+
+
+ Edit
+ Only editors can update this contract.
+
+
+
+
+
Only editors can update this contract.
+
+```
+
+Both `v-cannot` branches above compile to `v-if="!__can__('contract', 'submit')"` and `v-if="!__can__('contract', 'edit')"` respectively.
+
+### Keep `v-cannot` next to its `v-can`
+
+When `v-cannot` omits an expression, it must immediately follow the guarded block:
+
+```vue
+Ready!
+Not allowed
+
+
+
+
```
## Usage Rules & Errors
The transformer validates every template and throws descriptive errors when:
-- `v-cannot` does not immediately follow its matching `v-can`.
-- `v-can` appears on an element already using `v-else` / `v-else-if`.
-- `v-cannot` uses an argument, modifiers, or a `v-if` condition.
+- `v-can` expressions differ within the same `v-if` / `v-else-if` / `v-else` block (the guard is mirrored automatically, but mixed expressions are disallowed).
+- `v-cannot` without an argument is separated from its originating `v-can`.
+- `v-cannot` mixes in modifiers or a `v-if` condition (keep it standalone).
- Multiple `v-cannot` blocks exist for the same `v-can`.
- The expression is not a static dotted path like `can.resource.action`.
diff --git a/ROADMAP.md b/ROADMAP.md
index 7f4652d..2a135fa 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -110,7 +110,7 @@ Avant :
Après compilation :
```vue
-Voir le dossier
+Voir le dossier
```
### Exemple 2 — `v-if` + `v-can`
@@ -125,7 +125,7 @@ Avant :
Après compilation :
```vue
-
+
Modifier le contrat
```
@@ -141,8 +141,8 @@ Avant :
Après compilation :
```vue
-
Voir
-
Acces refuse
+
Voir
+
Acces refuse
```
### Exemple 4 — `v-if` / `v-else-if` / `v-else` + `v-can` / `v-cannot`
@@ -166,8 +166,8 @@ Résultat :
```vue
- Modifier
- Contactez votre admin
+ Modifier
+ Contactez votre admin
Vue manager
diff --git a/package.json b/package.json
index 2b94821..852e81b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@eduvia-app/nuxt-can",
- "version": "1.0.1",
+ "version": "1.1.0",
"description": "Nuxt directives (`v-can`, `v-cannot`) to layer permissions without touching business v-ifs.",
"author": "Eduvia ",
"homepage": "https://github.com/eduvia-app/nuxt-can#readme",
diff --git a/playground/app.vue b/playground/app.vue
index fa2fc48..b8cf6e3 100644
--- a/playground/app.vue
+++ b/playground/app.vue
@@ -1,15 +1,26 @@
-
- foo.bar
-
-
- cannot
-
+
+ `v-can` example
+
+
+ Draft branch
+
+
+ Pending branch
+
+
+ Fallback branch
+
+
+ Missing `can.foo.bar` permission
+
+
diff --git a/playground/permissions/__can__.ts b/playground/permissions/__can__.ts
index bab40ca..6faeb2b 100644
--- a/playground/permissions/__can__.ts
+++ b/playground/permissions/__can__.ts
@@ -1,4 +1,4 @@
-export function __can__(path: string[]) {
+export function __can__(...path: string[]) {
const key = path.join('.')
const allowed = new Set([
'employee.view',
@@ -8,5 +8,5 @@ export function __can__(path: string[]) {
const granted = allowed.has(key)
console.log('Checking permission:', key, '->', granted)
- return granted
+ return false
}
diff --git a/src/module/typegen.ts b/src/module/typegen.ts
index 3ee6587..3b22d41 100644
--- a/src/module/typegen.ts
+++ b/src/module/typegen.ts
@@ -36,7 +36,7 @@ export const generateTypeDeclaration = (permissions: Record) =
return `/* eslint-disable */
// Generated by nuxt-can – do not edit manually.
-type NuxtCanChecker = (path: string[]) => boolean | Promise
+type NuxtCanChecker = (...path: string[]) => boolean | Promise
type NuxtCanPermissions = ${permissionTree}
declare module 'vue' {
diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts
index 67c5256..964b373 100644
--- a/src/runtime/plugin.ts
+++ b/src/runtime/plugin.ts
@@ -9,7 +9,7 @@ interface NuxtCanRuntimeConfig {
canFunctionImport: string
}
-type NuxtCanChecker = (path: string[]) => boolean | Promise
+type NuxtCanChecker = (...path: string[]) => boolean | Promise
export default defineNuxtPlugin((nuxtApp) => {
const runtimeConfig = useRuntimeConfig()
diff --git a/src/runtime/transformer/patches.ts b/src/runtime/transformer/patches.ts
index ff9752d..0c783af 100644
--- a/src/runtime/transformer/patches.ts
+++ b/src/runtime/transformer/patches.ts
@@ -15,12 +15,27 @@ interface WalkerContext {
templateStart: number
patches: Patch[]
filename?: string
+ directiveCache: WeakMap
+ inheritedCans: WeakMap
}
interface PendingCan {
expression: string
}
+interface DirectiveSet {
+ canDirective: DirectiveNode | null
+ cannotDirective: DirectiveNode | null
+ ifDirective: DirectiveNode | null
+ elseDirective: DirectiveNode | null
+ elseIfDirective: DirectiveNode | null
+}
+
+type BranchKind = 'if' | 'else-if' | 'else' | 'plain'
+
+type DirectiveWithSegments = DirectiveNode & { [CAN_SEGMENTS_SYMBOL]?: string[] }
+
+const CAN_SEGMENTS_SYMBOL: unique symbol = Symbol('nuxt-can:segments')
const CAN_IDENTIFIER = new Set(['can', '$can'])
const SEGMENT_PATTERN = /^[\w-]+$/u
@@ -29,6 +44,8 @@ export function collectCanPatches(ast: RootNode, templateStart: number, filename
templateStart,
patches: [],
filename,
+ directiveCache: new WeakMap(),
+ inheritedCans: new WeakMap(),
}
walkChildren(ast.children, ctx)
@@ -37,6 +54,8 @@ export function collectCanPatches(ast: RootNode, templateStart: number, filename
}
function walkChildren(children: TemplateChildNode[], ctx: WalkerContext): void {
+ enforceConditionalChains(children, ctx)
+
let pendingCan: PendingCan | null = null
for (const child of children) {
@@ -71,20 +90,9 @@ function walkChildren(children: TemplateChildNode[], ctx: WalkerContext): void {
}
function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: PendingCan | null): PendingCan | null {
- let canDirective: DirectiveNode | null = null
- let cannotDirective: DirectiveNode | null = null
- let ifDirective: DirectiveNode | null = null
- let elseDirective: DirectiveNode | null = null
- let elseIfDirective: DirectiveNode | null = null
-
- for (const prop of element.props) {
- if (prop.type !== NodeTypes.DIRECTIVE) continue
- if (prop.name === 'can') canDirective = prop
- if (prop.name === 'cannot') cannotDirective = prop
- if (prop.name === 'if') ifDirective = prop
- if (prop.name === 'else') elseDirective = prop
- if (prop.name === 'else-if') elseIfDirective = prop
- }
+ const directives = getDirectiveSet(element, ctx)
+ const { canDirective, cannotDirective, ifDirective, elseDirective, elseIfDirective } = directives
+ const inheritedSegments = ctx.inheritedCans.get(element) ?? null
if (canDirective && cannotDirective) {
raiseDirectiveError(ctx, cannotDirective.loc, '`v-can` and `v-cannot` cannot be used on the same element.')
@@ -104,14 +112,26 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
if (canDirective) {
const expression = transformCanDirective({
canDirective,
- ifDirective,
ctx,
- hasElseLike: Boolean(elseDirective || elseIfDirective),
+ branch: determineBranchKind(directives),
+ conditionDirective: elseIfDirective ?? ifDirective,
+ elseDirective,
})
- return {
- expression,
- }
+ return { expression }
+ }
+
+ if (inheritedSegments) {
+ const expression = applyInheritedCanGuard({
+ segments: inheritedSegments,
+ ctx,
+ branch: determineBranchKind(directives),
+ conditionDirective: elseIfDirective ?? ifDirective,
+ elseDirective,
+ loc: element.loc,
+ })
+
+ return { expression }
}
return null
@@ -119,17 +139,14 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
function transformCanDirective(params: {
canDirective: DirectiveNode
- ifDirective: DirectiveNode | null
ctx: WalkerContext
- hasElseLike: boolean
+ branch: BranchKind
+ conditionDirective: DirectiveNode | null
+ elseDirective: DirectiveNode | null
}): string {
- const { canDirective, ifDirective, ctx, hasElseLike } = params
+ const { canDirective, ctx, branch, conditionDirective, elseDirective } = params
- if (hasElseLike) {
- raiseDirectiveError(ctx, canDirective.loc, '`v-can` cannot be used on `v-else` or `v-else-if` branches; wrap the conditional block in a and apply `v-can` inside it.')
- }
-
- if (canDirective.exp?.type !== NodeTypes.SIMPLE_EXPRESSION) {
+ if (canDirective.exp && canDirective.exp.type !== NodeTypes.SIMPLE_EXPRESSION) {
raiseDirectiveError(ctx, canDirective.loc, '`v-can` expects a static expression (for example `v-can="can.x.y"`).')
}
@@ -141,37 +158,34 @@ function transformCanDirective(params: {
raiseDirectiveError(ctx, canDirective.loc, '`v-can` does not accept modifiers.')
}
- const expressionContent = canDirective.exp.content?.trim()
+ const expressionContent = canDirective.exp?.content?.trim()
if (!expressionContent) {
raiseDirectiveError(ctx, canDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
}
- const pathSegments = parseCanExpression(expressionContent, ctx, canDirective.loc)
+ const pathSegments = getOrParseCanSegments(canDirective, expressionContent, ctx)
const canInvocation = buildCanInvocation(pathSegments)
- if (ifDirective && ifDirective.exp?.type === NodeTypes.SIMPLE_EXPRESSION) {
- const ifExpression = (ifDirective.exp.content || 'true').trim() || 'true'
-
- ctx.patches.push({
- start: ctx.templateStart + ifDirective.loc.start.offset,
- end: ctx.templateStart + ifDirective.loc.end.offset,
- text: `v-if="(${ifExpression}) && ${canInvocation}"`,
- })
-
- ctx.patches.push({
- start: ctx.templateStart + canDirective.loc.start.offset,
- end: ctx.templateStart + canDirective.loc.end.offset,
- text: '',
- })
- }
- else {
+ if (branch === 'plain') {
ctx.patches.push({
start: ctx.templateStart + canDirective.loc.start.offset,
end: ctx.templateStart + canDirective.loc.end.offset,
text: `v-if="${canInvocation}"`,
})
+
+ return canInvocation
}
+ mergeGuardIntoConditional({
+ branch,
+ conditionDirective,
+ elseDirective,
+ canInvocation,
+ ctx,
+ loc: canDirective.loc,
+ })
+
+ removeDirective(canDirective, ctx)
return canInvocation
}
@@ -183,8 +197,8 @@ function transformCannotDirective(params: {
}): void {
const { directive, pendingCan, ctx, ifDirective } = params
- if (directive.exp) {
- raiseDirectiveError(ctx, directive.loc, '`v-cannot` must not carry an expression (use `v-cannot` by itself).')
+ if (directive.exp && directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION) {
+ raiseDirectiveError(ctx, directive.loc, '`v-cannot` expects a static expression (for example `v-cannot="can.x.y"`).')
}
if (directive.arg) {
@@ -199,14 +213,26 @@ function transformCannotDirective(params: {
raiseDirectiveError(ctx, directive.loc, '`v-cannot` cannot be combined with `v-if`; remove the extra condition.')
}
- if (!pendingCan) {
- raiseDirectiveError(ctx, directive.loc, '`v-cannot` must immediately follow its `v-can`, and there can be only one `v-cannot` per `v-can` block.')
+ let expression = pendingCan?.expression
+
+ if (directive.exp) {
+ const expressionContent = directive.exp.content?.trim()
+ if (!expressionContent) {
+ raiseDirectiveError(ctx, directive.loc, '`v-cannot` requires a valid permission expression such as `can.employee.view`.')
+ }
+
+ const pathSegments = parseCanExpression(expressionContent, ctx, directive.loc)
+ expression = buildCanInvocation(pathSegments)
+ }
+
+ if (!expression) {
+ raiseDirectiveError(ctx, directive.loc, '`v-cannot` without an expression must immediately follow its matching `v-can`.')
}
ctx.patches.push({
start: ctx.templateStart + directive.loc.start.offset,
end: ctx.templateStart + directive.loc.end.offset,
- text: `v-if="!(${pendingCan.expression})"`,
+ text: `v-if="!(${expression})"`,
})
}
@@ -237,8 +263,242 @@ function parseCanExpression(expression: string, ctx: WalkerContext, loc: SourceL
function buildCanInvocation(segments: string[]): string {
const escapedSegments = segments
- .map(segment => `'${segment.replace(/\\/g, '\\\\').replace(/'/g, '\\u0027')}'`)
+ .map(segment => `'${segment.replace(/\\/g, '\\\\').replace(/'/g, '\u0027')}'`)
.join(', ')
- return `__can__([${escapedSegments}])`
+ return `__can__(${escapedSegments})`
+}
+
+function applyInheritedCanGuard(params: {
+ segments: string[]
+ ctx: WalkerContext
+ branch: BranchKind
+ conditionDirective: DirectiveNode | null
+ elseDirective: DirectiveNode | null
+ loc: SourceLocation
+}): string {
+ const { segments, ctx, branch, conditionDirective, elseDirective, loc } = params
+
+ if (branch === 'plain') {
+ raiseDirectiveError(ctx, loc, '`v-can` can only be inferred on `v-if` / `v-else-if` / `v-else` chains.')
+ }
+
+ const canInvocation = buildCanInvocation(segments)
+
+ mergeGuardIntoConditional({
+ branch,
+ conditionDirective,
+ elseDirective,
+ canInvocation,
+ ctx,
+ loc,
+ })
+
+ return canInvocation
+}
+
+function mergeGuardIntoConditional(params: {
+ branch: BranchKind
+ conditionDirective: DirectiveNode | null
+ elseDirective: DirectiveNode | null
+ canInvocation: string
+ ctx: WalkerContext
+ loc: SourceLocation
+}): void {
+ const { branch, conditionDirective, elseDirective, canInvocation, ctx, loc } = params
+
+ if (branch === 'if') {
+ if (!conditionDirective) {
+ raiseDirectiveError(ctx, loc, '`v-if` metadata missing while transforming `v-can`.')
+ }
+
+ const conditionExpression = getConditionExpression(conditionDirective, ctx, '`v-if` should carry a static expression when paired with `v-can`.')
+
+ ctx.patches.push({
+ start: ctx.templateStart + conditionDirective.loc.start.offset,
+ end: ctx.templateStart + conditionDirective.loc.end.offset,
+ text: `v-if="(${conditionExpression}) && ${canInvocation}"`,
+ })
+
+ return
+ }
+
+ if (branch === 'else-if') {
+ if (!conditionDirective) {
+ raiseDirectiveError(ctx, loc, '`v-else-if` metadata missing while transforming `v-can`.')
+ }
+
+ const conditionExpression = getConditionExpression(conditionDirective, ctx, '`v-else-if` should carry a static expression when paired with `v-can`.')
+
+ ctx.patches.push({
+ start: ctx.templateStart + conditionDirective.loc.start.offset,
+ end: ctx.templateStart + conditionDirective.loc.end.offset,
+ text: `v-else-if="(${conditionExpression}) && ${canInvocation}"`,
+ })
+
+ return
+ }
+
+ if (branch === 'else') {
+ if (!elseDirective) {
+ raiseDirectiveError(ctx, loc, '`v-else` metadata missing while transforming `v-can`.')
+ }
+
+ ctx.patches.push({
+ start: ctx.templateStart + elseDirective.loc.start.offset,
+ end: ctx.templateStart + elseDirective.loc.end.offset,
+ text: `v-else-if="${canInvocation}"`,
+ })
+
+ return
+ }
+}
+
+function determineBranchKind(directives: DirectiveSet): BranchKind {
+ if (directives.ifDirective) return 'if'
+ if (directives.elseIfDirective) return 'else-if'
+ if (directives.elseDirective) return 'else'
+ return 'plain'
+}
+
+function enforceConditionalChains(children: TemplateChildNode[], ctx: WalkerContext): void {
+ for (let index = 0; index < children.length; index += 1) {
+ const child = children[index]
+ if (!isTemplateChild(child) || child.type !== NodeTypes.ELEMENT) continue
+
+ const directives = getDirectiveSet(child as ElementNode, ctx)
+ if (!directives.ifDirective) continue
+
+ const chain = collectConditionalChain(children, index, ctx)
+ if (chain.length === 1) {
+ index = chain[chain.length - 1].index
+ continue
+ }
+
+ const canDirectives = chain.map(member => getDirectiveSet(member.node, ctx).canDirective)
+ const referenceDirective = canDirectives.find((directive): directive is DirectiveNode => Boolean(directive))
+
+ if (!referenceDirective) {
+ index = chain[chain.length - 1].index
+ continue
+ }
+
+ const expressionContent = referenceDirective.exp?.type === NodeTypes.SIMPLE_EXPRESSION ? referenceDirective.exp.content?.trim() : null
+ if (!expressionContent) {
+ raiseDirectiveError(ctx, referenceDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
+ }
+
+ const referenceSegments = getOrParseCanSegments(referenceDirective, expressionContent, ctx)
+
+ for (let i = 0; i < chain.length; i += 1) {
+ const directive = canDirectives[i]
+ if (!directive) {
+ ctx.inheritedCans.set(chain[i].node, referenceSegments)
+ continue
+ }
+
+ const content = directive.exp?.type === NodeTypes.SIMPLE_EXPRESSION ? directive.exp.content?.trim() : null
+ if (!content) {
+ raiseDirectiveError(ctx, directive.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
+ }
+
+ const segments = getOrParseCanSegments(directive, content, ctx)
+ if (!areSegmentsEqual(referenceSegments, segments)) {
+ raiseDirectiveError(ctx, directive.loc, '`v-can` expressions must match across every branch of the conditional chain.')
+ }
+ }
+
+ index = chain[chain.length - 1].index
+ }
+}
+
+function collectConditionalChain(children: TemplateChildNode[], startIndex: number, ctx: WalkerContext) {
+ const chain: Array<{ node: ElementNode, index: number }> = [
+ { node: children[startIndex] as ElementNode, index: startIndex },
+ ]
+
+ let cursor = startIndex + 1
+ while (cursor < children.length) {
+ const sibling = children[cursor]
+
+ if (sibling?.type === NodeTypes.COMMENT) {
+ cursor += 1
+ continue
+ }
+
+ if (sibling?.type === NodeTypes.TEXT && sibling.content.trim().length === 0) {
+ cursor += 1
+ continue
+ }
+
+ if (!isTemplateChild(sibling) || sibling.type !== NodeTypes.ELEMENT) {
+ break
+ }
+
+ const directives = getDirectiveSet(sibling as ElementNode, ctx)
+ if (directives.elseIfDirective || directives.elseDirective) {
+ chain.push({ node: sibling as ElementNode, index: cursor })
+ cursor += 1
+ continue
+ }
+
+ break
+ }
+
+ return chain
+}
+
+function getDirectiveSet(element: ElementNode, ctx: WalkerContext): DirectiveSet {
+ const cached = ctx.directiveCache.get(element)
+ if (cached) return cached
+
+ const directives: DirectiveSet = {
+ canDirective: null,
+ cannotDirective: null,
+ ifDirective: null,
+ elseDirective: null,
+ elseIfDirective: null,
+ }
+
+ for (const prop of element.props) {
+ if (prop.type !== NodeTypes.DIRECTIVE) continue
+ if (prop.name === 'can') directives.canDirective = prop
+ if (prop.name === 'cannot') directives.cannotDirective = prop
+ if (prop.name === 'if') directives.ifDirective = prop
+ if (prop.name === 'else') directives.elseDirective = prop
+ if (prop.name === 'else-if') directives.elseIfDirective = prop
+ }
+
+ ctx.directiveCache.set(element, directives)
+ return directives
+}
+
+function getConditionExpression(directive: DirectiveNode, ctx: WalkerContext, message: string): string {
+ if (!directive.exp || directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION) {
+ raiseDirectiveError(ctx, directive.loc, message)
+ }
+
+ return (directive.exp.content || 'true').trim() || 'true'
+}
+
+function removeDirective(directive: DirectiveNode, ctx: WalkerContext): void {
+ ctx.patches.push({
+ start: ctx.templateStart + directive.loc.start.offset,
+ end: ctx.templateStart + directive.loc.end.offset,
+ text: '',
+ })
+}
+
+function getOrParseCanSegments(directive: DirectiveNode, expression: string, ctx: WalkerContext): string[] {
+ const cached = (directive as DirectiveWithSegments)[CAN_SEGMENTS_SYMBOL]
+ if (cached) return cached
+
+ const segments = parseCanExpression(expression, ctx, directive.loc)
+ ;(directive as DirectiveWithSegments)[CAN_SEGMENTS_SYMBOL] = segments
+ return segments
+}
+
+function areSegmentsEqual(a: string[], b: string[]): boolean {
+ if (a.length !== b.length) return false
+ return a.every((segment, index) => segment === b[index])
}
diff --git a/test/chains.test.ts b/test/chains.test.ts
new file mode 100644
index 0000000..b0bf79b
--- /dev/null
+++ b/test/chains.test.ts
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url'
+import { describe, it, expect } from 'vitest'
+import { setup, $fetch } from '@nuxt/test-utils/e2e'
+
+describe('chains fixture', async () => {
+ await setup({
+ rootDir: fileURLToPath(new URL('./fixtures/chains', import.meta.url)),
+ })
+
+ it('hides every branch when permission is missing and falls back to v-cannot', async () => {
+ const html = await $fetch('/')
+ expect(html).not.toContain('id="branch-pending"')
+ expect(html).toContain('id="branch-denied"')
+ })
+
+ it('renders explicit v-cannot blocks even when detached from the chain', async () => {
+ const html = await $fetch('/')
+ expect(html).toContain('id="explicit-denied"')
+ expect(html).not.toContain('id="approve-action"')
+ })
+})
diff --git a/test/fixtures/basic/permissions/__can__.ts b/test/fixtures/basic/permissions/__can__.ts
index 7f3e6a4..cbe8799 100644
--- a/test/fixtures/basic/permissions/__can__.ts
+++ b/test/fixtures/basic/permissions/__can__.ts
@@ -4,7 +4,7 @@ const granted = new Set([
'contract.create',
])
-export function __can__(path: string[]) {
- const key = Array.isArray(path) ? path.join('.') : String(path)
+export function __can__(...path: string[]) {
+ const key = path.join('.')
return granted.has(key)
}
diff --git a/test/fixtures/chains/app.vue b/test/fixtures/chains/app.vue
new file mode 100644
index 0000000..f93f98a
--- /dev/null
+++ b/test/fixtures/chains/app.vue
@@ -0,0 +1,31 @@
+
+
+
+
+ Draft
+
+
+ Pending
+
+
+ Fallback
+
+
+ Permission missing
+
+
+
+
+
+ Approve
+
+
+ Need contract.approve
+
+
+
+
+
+
diff --git a/test/fixtures/chains/nuxt.config.ts b/test/fixtures/chains/nuxt.config.ts
new file mode 100644
index 0000000..e9a6849
--- /dev/null
+++ b/test/fixtures/chains/nuxt.config.ts
@@ -0,0 +1,12 @@
+import NuxtCan from '../../../src/module'
+
+export default defineNuxtConfig({
+ modules: [NuxtCan],
+ nuxtCan: {
+ permissions: {
+ employee: ['view'],
+ contract: ['approve'],
+ },
+ canFunctionImport: '~/permissions/__can__',
+ },
+})
diff --git a/test/fixtures/chains/package.json b/test/fixtures/chains/package.json
new file mode 100644
index 0000000..b62fbdb
--- /dev/null
+++ b/test/fixtures/chains/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "name": "chains",
+ "type": "module"
+}
diff --git a/test/fixtures/chains/permissions/__can__.ts b/test/fixtures/chains/permissions/__can__.ts
new file mode 100644
index 0000000..f0b9bdc
--- /dev/null
+++ b/test/fixtures/chains/permissions/__can__.ts
@@ -0,0 +1,6 @@
+const granted = new Set([])
+
+export function __can__(...path: string[]) {
+ const key = path.join('.')
+ return granted.has(key)
+}
diff --git a/test/transform-can.test.ts b/test/transform-can.test.ts
index 27ef30d..2fc54e1 100644
--- a/test/transform-can.test.ts
+++ b/test/transform-can.test.ts
@@ -18,8 +18,8 @@ describe('transformCan', () => {
Refus
`)
- expect(code).toContain(`v-if="__can__(['employee', 'view'])"`)
- expect(code).toContain(`v-if="!(__can__(['employee', 'view']))"`)
+ expect(code).toContain(`v-if="__can__('employee', 'view')"`)
+ expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
})
it('merges existing v-if expressions with the generated guard', () => {
@@ -27,7 +27,7 @@ describe('transformCan', () => {
`)
- expect(code).toContain(`v-if="(isReady) && __can__(['contract', 'create'])"`)
+ expect(code).toContain(`v-if="(isReady) && __can__('contract', 'create')"`)
})
it('throws when v-cannot is used without a preceding v-can', () => {
@@ -36,7 +36,7 @@ describe('transformCan', () => {
id: TEST_FILE,
})
- expect(exec).toThrow(/must immediately follow its `v-can`/)
+ expect(exec).toThrow(/without an expression must immediately follow/)
})
it('throws when the expression does not start with can.*', () => {
@@ -48,15 +48,61 @@ describe('transformCan', () => {
expect(exec).toThrow(/expressions must start with `can\.`/)
})
- it('throws when v-can is added to a v-else branch', () => {
+ it('mirrors guards across v-if / v-else-if / v-else chains when the first branch uses v-can', () => {
+ const code = runTransform(`
+
+
+
+ Denied
+ `)
+
+ expect(code).toContain(`v-if="(ready) && __can__('employee', 'view')"`)
+ expect(code).toContain(`v-else-if="(later) && __can__('employee', 'view')"`)
+ expect(code).toContain(`v-else-if="__can__('employee', 'view')"`)
+ expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
+ })
+
+ it('throws when branches declare different v-can expressions', () => {
const exec = () => transformCan({
code: buildSFC(`
-
-
+
+
`),
id: TEST_FILE,
})
- expect(exec).toThrow(/cannot be used on `v-else`/)
+ expect(exec).toThrow(/must match across every branch/)
+ })
+
+ it('allows explicit expressions on v-cannot without a preceding v-can', () => {
+ const code = runTransform('Denied
')
+ expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
+ })
+
+ it('requires adjacent placement for v-cannot without an expression', () => {
+ const exec = () => transformCan({
+ code: buildSFC(`
+ Allowed
+
+ `),
+ id: TEST_FILE,
+ })
+
+ expect(exec).toThrow(/without an expression must immediately follow/)
+ })
+
+ it('requires adjacency even when only empty siblings exist between v-can and v-cannot', () => {
+ const exec = () => transformCan({
+ code: buildSFC(`
+ Allowed
+
+ Denied
+ `),
+ id: TEST_FILE,
+ })
+
+ expect(exec).toThrow(/without an expression must immediately follow/)
})
})