Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29d9c23800 | ||
|
|
7d823307fa |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -5,6 +5,19 @@ 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.1] - 2025-11-15
|
||||
### Changed
|
||||
- Evaluate `__can__` guards before business expressions when merging into `v-if` / `v-else-if` branches to keep short-circuit order predictable.
|
||||
|
||||
## [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 +39,7 @@ 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.1]: https://github.com/eduvia-app/nuxt-can/releases/tag/v1.1.1
|
||||
[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
|
||||
|
||||
90
README.md
90
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
|
||||
<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="__can__('employee', 'edit') && (isReady)">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 it’s 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="__can__('foo', 'bar') && (status === 'draft')">
|
||||
Draft state
|
||||
</div>
|
||||
<div v-else-if="__can__('foo', 'bar') && (status === 'pending')">
|
||||
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`.
|
||||
|
||||
|
||||
14
ROADMAP.md
14
ROADMAP.md
@@ -59,7 +59,7 @@ Ce document sert de guide de travail pour construire le plugin Nuxt et son trans
|
||||
### 🚀 Phase 4 — Transformation des nœuds `v-can`
|
||||
- Convertir l’expression en chemin `['segment1','segment2',…]`.
|
||||
- Chercher un `v-if` existant :
|
||||
- `v-if="expr"` → `v-if="(expr) && __can__(path)"`.
|
||||
- `v-if="expr"` → `v-if="__can__(path) && (expr)"`.
|
||||
- Sans `v-if` → ajouter `v-if="__can__(path)"`.
|
||||
- Interdire `v-can` sur des éléments possédant déjà `v-else` / `v-else-if` (lever une erreur compilateur).
|
||||
|
||||
@@ -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="__can__('contract', 'edit') && (isReady)">
|
||||
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="__can__('employee', 'view') && (ctaVisible)">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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@eduvia-app/nuxt-can",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.1",
|
||||
"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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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="${canInvocation} && (${conditionExpression})"`,
|
||||
})
|
||||
|
||||
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="${canInvocation} && (${conditionExpression})"`,
|
||||
})
|
||||
|
||||
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
21
test/chains.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
4
test/fixtures/basic/permissions/__can__.ts
vendored
4
test/fixtures/basic/permissions/__can__.ts
vendored
@@ -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
31
test/fixtures/chains/app.vue
vendored
Normal 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
12
test/fixtures/chains/nuxt.config.ts
vendored
Normal 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
5
test/fixtures/chains/package.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "chains",
|
||||
"type": "module"
|
||||
}
|
||||
6
test/fixtures/chains/permissions/__can__.ts
vendored
Normal file
6
test/fixtures/chains/permissions/__can__.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
const granted = new Set<string>([])
|
||||
|
||||
export function __can__(...path: string[]) {
|
||||
const key = path.join('.')
|
||||
return granted.has(key)
|
||||
}
|
||||
@@ -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="__can__('contract', 'create') && (isReady)"`)
|
||||
})
|
||||
|
||||
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="__can__('employee', 'view') && (ready)"`)
|
||||
expect(code).toContain(`v-else-if="__can__('employee', 'view') && (later)"`)
|
||||
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/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user