feat: add directive transformer runtime

This commit is contained in:
stanig2106
2025-11-14 05:22:46 +01:00
parent 0c19594823
commit 534bc39197
16 changed files with 506 additions and 37 deletions

23
.zed/debug.json Normal file
View File

@@ -0,0 +1,23 @@
// Project-local debug tasks
//
// For more documentation on how to configure debug tasks,
// see: https://zed.dev/docs/debugger
[
{
"adapter": "JavaScript",
"label": "package.json > dev",
"request": "launch",
"type": "pwa-node",
"args": [
"run",
"dev"
],
"cwd": "/Users/stani/Documents/campus-prive/apti-connect/nuxt-can",
"runtimeExecutable": "$ZED_CUSTOM_TYPESCRIPT_RUNNER",
"env": {},
"runtimeArgs": [
"--inspect-brk"
],
"console": "integratedTerminal"
}
]

View File

@@ -1,41 +1,100 @@
<!--
Get your module up and running quickly.
Find and replace all on all files (CMD+SHIFT+F):
- Name: My Module
- Package name: my-module
- Description: My new Nuxt module
-->
# My Module
# nuxt-can
[![npm version][npm-version-src]][npm-version-href]
[![npm downloads][npm-downloads-src]][npm-downloads-href]
[![License][license-src]][license-href]
[![Nuxt][nuxt-src]][nuxt-href]
My new Nuxt module for doing amazing things.
`nuxt-can` apporte deux directives Vue (`v-can`, `v-cannot`) pour écrire des permissions ultra lisibles dans les templates Nuxt. Les directives sont transformées à la compilation en appels à une fonction `__can__` fournie par lapplication hôte tout en restant 100% tree-shake friendly et parfaitement typées côté TypeScript.
- [✨ &nbsp;Release Notes](/CHANGELOG.md)
<!-- - [🏀 Online playground](https://stackblitz.com/github/your-org/my-module?file=playground%2Fapp.vue) -->
<!-- - [📖 &nbsp;Documentation](https://example.com) -->
## Features
<!-- Highlight some of the features your module provide here -->
- ⛰ &nbsp;Foo
- 🚠 &nbsp;Bar
- 🌲 &nbsp;Baz
- ✅ Directives `v-can` / `v-cannot` transformées en `v-if` / `!v-if`
- ✅ Merge automatique avec des `v-if` existants (pas de runtime inutile)
- ✅ Proxy global `can` + types générés selon votre configuration de permissions
- ✅ Import configurable de la fonction `__can__` (store, API, …)
- ✅ Erreurs compile-time claires (cas interdits détectés avant build)
## Quick Setup
Install the module to your Nuxt application with one command:
Installez le module dans votre application Nuxt :
```bash
npx nuxi module add my-module
npm install nuxt-can
# ou
npx nuxi module add nuxt-can
```
That's it! You can now use My Module in your Nuxt app ✨
Déclarez ensuite le module et configurez vos permissions :
```ts
// nuxt.config.ts
import NuxtCan from 'nuxt-can'
export default defineNuxtConfig({
modules: [NuxtCan],
nuxtCan: {
permissions: {
employee: ['view', 'edit'],
contract: ['create'],
},
canFunctionImport: '~/permissions/can', // chemin vers votre implémentation __can__
},
})
```
Implémentez la fonction `__can__` dans le fichier ciblé (`~/permissions/can` dans lexemple):
```ts
// permissions/can.ts
const permissionsStore = usePermissionsStore()
export function __can__(path: string[]) {
return permissionsStore.check(path.join('.'))
}
```
Le module génère automatiquement un proxy `can` typé :
```vue
<template>
<button v-can="can.employee.view">Voir le dossier</button>
<button v-if="isReady" v-can="can.employee.edit">
Modifier
</button>
<p v-cannot>Accès refusé</p>
</template>
```
Résultat compilé :
```vue
<button v-if="__can__(['employee', 'view'])">Voir le dossier</button>
<button v-if="(isReady) && __can__(['employee', 'edit'])">Modifier</button>
<p v-if="!(__can__(['employee', 'edit']))">Accès refusé</p>
```
### Règles et erreurs surveillées
- `v-cannot` doit suivre immédiatement un `v-can` (aucun autre composant entre les deux).
- Un `v-can` ne peut pas cohabiter avec `v-else` / `v-else-if`.
- `v-cannot` naccepte ni expression, ni argument, ni `v-if`.
- Un seul `v-cannot` par `v-can`.
- Les expressions doivent suivre le pattern `can.x.y` (au moins deux niveaux après `can`).
Toute violation produit une erreur compile-time lisible.
### Types générés
À partir de la clé `permissions`, le module génère un fichier `.d.ts` qui expose :
- `can` / `$can` sur `ComponentCustomProperties`
- `__can__` sur les templates/scripts Nuxt
- `NuxtApp.$can` et `NuxtApp.$__can__`
La DX template + TypeScript reste donc impeccable sans configuration additionnelle.
## Contribution

View File

@@ -5,6 +5,9 @@
"name": "nuxt-can",
"dependencies": {
"@nuxt/kit": "^4.2.1",
"@vue/language-core": "^3.1.3",
"diff": "^8.0.2",
"htmlparser2": "^10.0.0",
},
"devDependencies": {
"@nuxt/devtools": "^3.1.0",
@@ -813,7 +816,7 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
@@ -971,6 +974,8 @@
"html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
"htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"http-shutdown": ["http-shutdown@1.2.2", "", {}, "sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw=="],
@@ -1707,6 +1712,8 @@
"@vitejs/plugin-vue-jsx/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5", "", {}, "sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ=="],
"@vue/compiler-core/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@@ -1723,6 +1730,8 @@
"csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"eslint-plugin-import-x/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],

View File

@@ -34,7 +34,10 @@
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
},
"dependencies": {
"@nuxt/kit": "^4.2.1"
"@nuxt/kit": "^4.2.1",
"@vue/language-core": "^3.1.3",
"diff": "^8.0.2",
"htmlparser2": "^10.0.0"
},
"devDependencies": {
"@nuxt/devtools": "^3.1.0",

View File

@@ -1,8 +1,41 @@
<template>
<div>
Nuxt module playground!
<button v-if="true" id="btn-view" v-can="true">
Voir le dossier 2
</button>
<div v-cannot>
v-else
</div>
<button v-if="isReady && can.employee.edit" id="btn-edit" v-can="true">
Modifier le dossier
</button>
</template>
<script setup>
<script setup lang="ts">
const isReady = true
const can = {
employee: {
view: 'view_employee',
edit: 'edit_employee',
},
}
</script>
<style scoped>
.page {
padding: 2rem;
font-family: system-ui, sans-serif;
}
section {
margin-top: 1.5rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 1rem;
background: #fff;
}
button {
margin-right: 0.75rem;
}
</style>

View File

@@ -1,5 +1,12 @@
export default defineNuxtConfig({
modules: ['../src/module'],
devtools: { enabled: true },
myModule: {},
nuxtCan: {
reporter: true
// permissions: {
// employee: ['view', 'edit'],
// contract: ['create'],
// },
// canFunctionImport: '~/permissions/__can__',
},
})

View File

@@ -0,0 +1,12 @@
export function __can__(path: string[]) {
const key = path.join('.')
const allowed = new Set([
'employee.view',
'employee.edit',
'contract.create',
])
// return allowed.has(key)
console.log('Checking permission:', key)
return false
}

View File

@@ -1,21 +1,31 @@
import { defineNuxtModule, addPlugin, createResolver } from '@nuxt/kit'
// modules/nuxt-can.ts
import { defineNuxtModule, addVitePlugin } from '@nuxt/kit'
import { transformCan } from './runtime/transformer/transform-can'
// Module options TypeScript interface definition
export interface ModuleOptions {
0: string
reporter: boolean
}
export const CONFIG_KEY = 'nuxtCan'
export default defineNuxtModule<ModuleOptions>({
meta: {
name: 'my-module',
configKey: 'myModule',
name: 'nuxt-can',
configKey: CONFIG_KEY,
},
// Default configuration options of the Nuxt module
defaults: {},
setup(_options, _nuxt) {
const resolver = createResolver(import.meta.url)
// Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack`
addPlugin(resolver.resolve('./runtime/plugin'))
defaults: {
reporter: false,
},
setup(options) {
addVitePlugin({
name: 'vite-plugin-nuxt-can',
enforce: 'pre',
transform(code, id) {
return transformCan({ code, id, reporter: options.reporter })
},
})
},
})

View File

@@ -0,0 +1,30 @@
import type { SourceLocation } from '@vue/compiler-dom'
export interface ErrorContext {
filename?: string
}
export function raiseDirectiveError(ctx: ErrorContext, loc: SourceLocation, message: string): never {
const cwd = process.cwd()
const filename = ctx.filename
? filenameRelativeToCwd(ctx.filename, cwd)
: '<template>'
const position = `${filename}:${loc.start.line}:${loc.start.column}`
const error = new Error(`[nuxt-can] ${message} (${position})`)
error.name = 'NuxtCanError'
const stackLine = ` at ${position}`
if (error.stack) {
const [head, ...rest] = error.stack.split('\n')
error.stack = [head, stackLine, ...rest.slice(1)].join('\n')
}
else {
error.stack = `${error.name}: ${error.message}\n${stackLine}`
}
throw error
}
function filenameRelativeToCwd(filename: string, cwd: string): string {
return filename.startsWith(cwd)
? filename.slice(cwd.length).replace(/^\//, '')
: filename
}

View File

@@ -0,0 +1,5 @@
import type { TemplateChildNode } from '@vue/compiler-dom'
export function isTemplateChild(node: unknown): node is TemplateChildNode {
return !!node && typeof node === 'object' && 'type' in node
}

View File

@@ -0,0 +1,7 @@
import type { Patch } from './types'
export function applyPatches(source: string, patches: Patch[]): string {
return [...patches]
.sort((a, b) => b.start - a.start)
.reduce((acc, patch) => acc.slice(0, patch.start) + patch.text + acc.slice(patch.end), source)
}

View File

@@ -0,0 +1,186 @@
import {
type RootNode,
type TemplateChildNode,
type ElementNode,
type DirectiveNode,
NodeTypes,
} from '@vue/compiler-dom'
import type { Patch } from './types'
import { isTemplateChild } from './nodes'
import { raiseDirectiveError } from './errors'
interface WalkerContext {
templateStart: number
patches: Patch[]
filename?: string
}
interface PendingCan {
expression: string
}
export function collectCanPatches(ast: RootNode, templateStart: number, filename?: string): Patch[] {
const ctx: WalkerContext = {
templateStart,
patches: [],
filename,
}
walkChildren(ast.children, ctx)
return ctx.patches
}
function walkChildren(children: TemplateChildNode[], ctx: WalkerContext): void {
let pendingCan: PendingCan | null = null
for (const child of children) {
if (!isTemplateChild(child)) {
pendingCan = null
continue
}
if (child.type === NodeTypes.TEXT) {
if (child.content.trim().length > 0) {
pendingCan = null
}
continue
}
if (child.type === NodeTypes.COMMENT) {
continue
}
if (child.type !== NodeTypes.ELEMENT) {
pendingCan = null
continue
}
const nextPending = handleElement(child as ElementNode, ctx, pendingCan)
pendingCan = nextPending
if (Array.isArray(child.children) && child.children.length > 0) {
walkChildren(child.children, ctx)
}
}
}
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
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 (canDirective && cannotDirective) {
raiseDirectiveError(ctx, cannotDirective.loc, '`v-can` and `v-cannot` cannot be used on the same element.')
}
if (cannotDirective) {
transformCannotDirective({
directive: cannotDirective,
ctx,
pendingCan,
ifDirective,
})
return null
}
if (canDirective) {
const expression = transformCanDirective({
canDirective,
ifDirective,
ctx,
})
return {
expression,
}
}
return null
}
function transformCanDirective(params: {
canDirective: DirectiveNode
ifDirective: DirectiveNode | null
ctx: WalkerContext
}): string {
const { canDirective, ifDirective, ctx } = params
if (canDirective.exp?.type !== NodeTypes.SIMPLE_EXPRESSION) {
raiseDirectiveError(ctx, canDirective.loc, '`v-can` expects a static expression (for example `v-can="can.x.y"`).')
}
const canExpression = canDirective.exp.content?.trim()
if (!canExpression) {
raiseDirectiveError(ctx, canDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
}
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="(${canExpression}) && (${ifExpression})"`,
})
ctx.patches.push({
start: ctx.templateStart + canDirective.loc.start.offset,
end: ctx.templateStart + canDirective.loc.end.offset,
text: '',
})
}
else {
ctx.patches.push({
start: ctx.templateStart + canDirective.loc.start.offset,
end: ctx.templateStart + canDirective.loc.end.offset,
text: `v-if="${canExpression}"`,
})
}
return canExpression
}
function transformCannotDirective(params: {
directive: DirectiveNode
pendingCan: PendingCan | null
ifDirective: DirectiveNode | null
ctx: WalkerContext
}): 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.arg) {
raiseDirectiveError(ctx, directive.loc, '`v-cannot` does not accept arguments.')
}
if (directive.modifiers.length) {
raiseDirectiveError(ctx, directive.loc, '`v-cannot` does not accept modifiers.')
}
if (ifDirective) {
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.')
}
ctx.patches.push({
start: ctx.templateStart + directive.loc.start.offset,
end: ctx.templateStart + directive.loc.end.offset,
text: `v-if="!(${pendingCan.expression})"`,
})
}

View File

@@ -0,0 +1,19 @@
import { diffLines } from 'diff'
import pc from 'picocolors'
import type { DiffPayload } from './types'
export function reportTemplateDiff({ before, after, id }: DiffPayload): void {
const diffs = diffLines(before, after)
const body = diffs
.map((part) => {
if (part.added) return pc.green(part.value)
if (part.removed) return pc.red(part.value)
return part.value
})
.join('')
console.log(pc.bold(`\n===== DIFF ${id} =====`))
console.log(body)
console.log(pc.bold('============================\n'))
}

View File

@@ -0,0 +1,45 @@
import { parse as parseSFC } from '@vue/compiler-sfc'
import { parse as parseTemplate } from '@vue/compiler-dom'
import { collectCanPatches } from './patches'
import { applyPatches } from './patcher'
import { reportTemplateDiff } from './reporter'
interface TransformInput {
code: string
id: string
reporter?: boolean
}
export function transformCan({ code, id, reporter }: TransformInput) {
if (!id.endsWith('.vue')) return null
const sfc = parseSFC(code)
const tpl = sfc.descriptor.template
if (!tpl) return null
const templateStart = tpl.loc.start.offset
const templateEnd = tpl.loc.end.offset
const before = code.slice(templateStart, templateEnd)
if (!before.includes('v-can'))
return null
const ast = parseTemplate(before, { comments: true })
const patches = collectCanPatches(ast, templateStart, id)
if (!patches.length) return null
const nextCode = applyPatches(code, patches)
if (reporter)
reportTemplateDiff({
before: code,
after: nextCode,
id,
})
return {
code: nextCode,
map: null,
}
}

View File

@@ -0,0 +1,11 @@
export interface Patch {
start: number
end: number
text: string
}
export interface DiffPayload {
before: string
after: string
id: string
}

10
src/runtime/utils.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { SourceLocation, TransformContext } from '@vue/compiler-dom'
export const reportError = (context: TransformContext, message: string, loc: SourceLocation) => {
context.onError({
name: 'CanDirectiveError',
code: 0,
loc: loc,
message: `[nuxt-can] ${message}`,
})
}