feat: mirror v-can guard chains

This commit is contained in:
stanig2106
2025-11-14 19:23:18 +01:00
parent 2c4fe3f353
commit 7d823307fa
16 changed files with 573 additions and 138 deletions

View File

@@ -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

View File

@@ -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
<button v-if="__can__(['employee', 'view'])">View profile</button>
<button v-if="(isReady) && __can__(['employee', 'edit'])">Edit profile</button>
<p v-if="!(__can__(['employee', 'edit']))">Access denied</p>
<button v-if="__can__('employee', 'view')">View profile</button>
<button v-if="(isReady) && __can__('employee', 'edit')">Edit profile</button>
<p v-if="!(__can__('employee', 'edit'))">Access denied</p>
```
## 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 its no longer required.
```vue
<div v-if="status === 'draft'" v-can="can.foo.bar">
Draft state
</div>
<div v-else-if="status === 'pending'">
Pending state
</div>
<div v-else>
Fallback state
</div>
<div v-cannot="can.foo.bar">
Missing permission
</div>
```
Transforms into:
```vue
<div v-if="(status === 'draft') && __can__('foo', 'bar')">
Draft state
</div>
<div v-else-if="(status === 'pending') && __can__('foo', 'bar')">
Pending state
</div>
<div v-else-if="__can__('foo', 'bar')">
Fallback state
</div>
<div v-if="!__can__('foo', 'bar')">
Missing permission
</div>
```
### 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
<button v-can="can.contract.submit">Submit contract</button>
<p v-cannot="can.contract.submit">Contact your admin to unlock submissions.</p>
<template>
<button v-if="isReady" v-can="can.contract.edit">Edit</button>
<p v-cannot>Only editors can update this contract.</p>
</template>
<!-- Need to wrap the fallback? pass the expression explicitly -->
<div class="notice">
<p v-cannot="can.contract.edit">Only editors can update this contract.</p>
</div>
```
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
<div v-if="isReady" v-can="can.foo.bar">Ready!</div>
<p v-cannot>Not allowed</p> <!-- adjacent, guard is inferred -->
<div>
<p v-cannot>Not allowed</p> <!-- wrapped, missing explicit expression -->
</div>
<div>
<p v-cannot="can.foo.bar">Not allowed</p> <!-- wrapper + explicit permission -->
</div>
```
## 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`.

View File

@@ -110,7 +110,7 @@ Avant :
Après compilation :
```vue
<button v-if="__can__(['employee', 'view'])">Voir le dossier</button>
<button v-if="__can__('employee', 'view')">Voir le dossier</button>
```
### Exemple 2 — `v-if` + `v-can`
@@ -125,7 +125,7 @@ Avant :
Après compilation :
```vue
<div v-if="(isReady) && __can__(['contract', 'edit'])">
<div v-if="(isReady) && __can__('contract', 'edit')">
Modifier le contrat
</div>
```
@@ -141,8 +141,8 @@ Avant :
Après compilation :
```vue
<button v-if="(ctaVisible) && __can__(['employee', 'view'])">Voir</button>
<p v-if="!__can__(['employee', 'view'])">Acces refuse</p>
<button v-if="(ctaVisible) && __can__('employee', 'view')">Voir</button>
<p v-if="!__can__('employee', 'view')">Acces refuse</p>
```
### Exemple 4 — `v-if` / `v-else-if` / `v-else` + `v-can` / `v-cannot`
@@ -166,8 +166,8 @@ Résultat :
```vue
<template v-if="isOwner">
<button v-if="__can__(['employee', 'edit'])">Modifier</button>
<p v-if="!__can__(['employee', 'edit'])">Contactez votre admin</p>
<button v-if="__can__('employee', 'edit')">Modifier</button>
<p v-if="!__can__('employee', 'edit')">Contactez votre admin</p>
</template>
<template v-else-if="isManager">
<p>Vue manager</p>

View File

@@ -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 <engineering@eduvia.app>",
"homepage": "https://github.com/eduvia-app/nuxt-can#readme",

View File

@@ -1,15 +1,26 @@
<template>
<main class="page">
<div v-can="can.baz.dskj">
foo.bar
</div>
<div v-cannot>
cannot
</div>
<section class="card">
<h1>`v-can` example</h1>
<div v-if="phase === 'draft'" v-can="can.foo.bar" class="block">
Draft branch
</div>
<div v-else-if="phase === 'pending'" class="block">
Pending branch
</div>
<div v-else class="block">
Fallback branch
</div>
<div v-cannot class="denied">
Missing `can.foo.bar` permission
</div>
</section>
</main>
</template>
<script setup lang="ts">
const phase = 'pending'
</script>
<style scoped>
@@ -18,66 +29,23 @@
font-family: system-ui, sans-serif;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
section {
margin-top: 1.5rem;
.card {
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 1rem;
padding: 1.5rem;
background: #fff;
max-width: 32rem;
}
.card h2 {
margin-top: 0;
}
button {
margin-right: 0.75rem;
}
.ghost {
background: transparent;
border: 1px solid #cbd5f5;
padding: 0.35rem 0.75rem;
border-radius: 999px;
cursor: pointer;
}
.toggle {
display: inline-flex;
gap: 0.35rem;
align-items: center;
margin-top: 0.75rem;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
border-bottom: 1px solid #edf2f7;
}
li:last-child {
border-bottom: 0;
}
.allowed {
color: #15803d;
.block {
border: 1px dashed #cbd5f5;
border-radius: 0.5rem;
padding: 0.75rem;
margin-block: 0.5rem;
}
.denied {
margin-top: 1rem;
color: #b91c1c;
}
</style>

View File

@@ -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
}

View File

@@ -36,7 +36,7 @@ export const generateTypeDeclaration = (permissions: Record<string, string[]>) =
return `/* eslint-disable */
// Generated by nuxt-can do not edit manually.
type NuxtCanChecker = (path: string[]) => boolean | Promise<boolean>
type NuxtCanChecker = (...path: string[]) => boolean | Promise<boolean>
type NuxtCanPermissions = ${permissionTree}
declare module 'vue' {

View File

@@ -9,7 +9,7 @@ interface NuxtCanRuntimeConfig {
canFunctionImport: string
}
type NuxtCanChecker = (path: string[]) => boolean | Promise<boolean>
type NuxtCanChecker = (...path: string[]) => boolean | Promise<boolean>
export default defineNuxtPlugin((nuxtApp) => {
const runtimeConfig = useRuntimeConfig()

View File

@@ -15,12 +15,27 @@ interface WalkerContext {
templateStart: number
patches: Patch[]
filename?: string
directiveCache: WeakMap<ElementNode, DirectiveSet>
inheritedCans: WeakMap<ElementNode, string[]>
}
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 <template> 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])
}

21
test/chains.test.ts Normal file
View File

@@ -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"')
})
})

View File

@@ -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)
}

31
test/fixtures/chains/app.vue vendored Normal file
View File

@@ -0,0 +1,31 @@
<template>
<main>
<section>
<div id="branch-draft" v-if="phase === 'draft'" v-can="can.employee.view">
Draft
</div>
<div id="branch-pending" v-else-if="phase === 'pending'">
Pending
</div>
<div id="branch-fallback" v-else>
Fallback
</div>
<p id="branch-denied" v-cannot>
Permission missing
</p>
</section>
<section>
<button id="approve-action" v-can="can.contract.approve">
Approve
</button>
<p id="explicit-denied" v-cannot="can.contract.approve">
Need contract.approve
</p>
</section>
</main>
</template>
<script setup lang="ts">
const phase = 'pending'
</script>

12
test/fixtures/chains/nuxt.config.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
import NuxtCan from '../../../src/module'
export default defineNuxtConfig({
modules: [NuxtCan],
nuxtCan: {
permissions: {
employee: ['view'],
contract: ['approve'],
},
canFunctionImport: '~/permissions/__can__',
},
})

5
test/fixtures/chains/package.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"private": true,
"name": "chains",
"type": "module"
}

View File

@@ -0,0 +1,6 @@
const granted = new Set<string>([])
export function __can__(...path: string[]) {
const key = path.join('.')
return granted.has(key)
}

View File

@@ -18,8 +18,8 @@ describe('transformCan', () => {
<p v-cannot>Refus</p>
`)
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', () => {
<div v-if="isReady" v-can="can.contract.create" />
`)
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(`
<div v-if="ready" v-can="can.employee.view"></div>
<div v-else-if="later"></div>
<div v-else></div>
<p v-cannot>Denied</p>
`)
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(`
<div v-if="ready"></div>
<div v-else v-can="can.employee.view"></div>
<div v-if="ready" v-can="can.employee.view"></div>
<div v-else v-can="can.contract.create"></div>
`),
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('<p v-cannot="can.employee.view">Denied</p>')
expect(code).toContain(`v-if="!(__can__('employee', 'view'))"`)
})
it('requires adjacent placement for v-cannot without an expression', () => {
const exec = () => transformCan({
code: buildSFC(`
<div v-can="can.employee.view">Allowed</div>
<div>
<p v-cannot>Denied</p>
</div>
`),
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(`
<div v-can="can.employee.view">Allowed</div>
<div></div>
<div v-cannot>Denied</div>
`),
id: TEST_FILE,
})
expect(exec).toThrow(/without an expression must immediately follow/)
})
})