feat: wire global runtime injection and playground

This commit is contained in:
stanig2106
2025-11-14 06:15:33 +01:00
parent 534bc39197
commit f12bc0ce85
18 changed files with 516 additions and 54 deletions

3
nuxt.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
import './types/shims-nuxt-can.d.ts'
export {}

View File

@@ -36,8 +36,7 @@
"dependencies": { "dependencies": {
"@nuxt/kit": "^4.2.1", "@nuxt/kit": "^4.2.1",
"@vue/language-core": "^3.1.3", "@vue/language-core": "^3.1.3",
"diff": "^8.0.2", "diff": "^8.0.2"
"htmlparser2": "^10.0.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/devtools": "^3.1.0", "@nuxt/devtools": "^3.1.0",

View File

@@ -1,24 +1,15 @@
<template> <template>
<button v-if="true" id="btn-view" v-can="true"> <main class="page">
Voir le dossier 2 <div v-can="can.baz.dskj">
</button> foo.bar
<div v-cannot> </div>
v-else <div v-cannot>
</div> cannot
</div>
<button v-if="isReady && can.employee.edit" id="btn-edit" v-can="true"> </main>
Modifier le dossier
</button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const isReady = true
const can = {
employee: {
view: 'view_employee',
edit: 'edit_employee',
},
}
</script> </script>
<style scoped> <style scoped>
@@ -27,6 +18,13 @@ const can = {
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
section { section {
margin-top: 1.5rem; margin-top: 1.5rem;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -35,7 +33,51 @@ section {
background: #fff; background: #fff;
} }
.card h2 {
margin-top: 0;
}
button { button {
margin-right: 0.75rem; 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;
}
.denied {
color: #b91c1c;
}
</style> </style>

View File

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

View File

@@ -6,7 +6,7 @@ export function __can__(path: string[]) {
'contract.create', 'contract.create',
]) ])
// return allowed.has(key) const granted = allowed.has(key)
console.log('Checking permission:', key) console.log('Checking permission:', key, '->', granted)
return false return granted
} }

View File

@@ -1,30 +1,60 @@
// modules/nuxt-can.ts import { addPlugin, addTemplate, addTypeTemplate, addVitePlugin, createResolver, defineNuxtModule, resolvePath } from '@nuxt/kit'
import { defineNuxtModule, addVitePlugin } from '@nuxt/kit'
import { transformCan } from './runtime/transformer/transform-can' import { transformCan } from './runtime/transformer/transform-can'
import { CAN_IMPORT_TEMPLATE_FILENAME, CONFIG_KEY, DEFAULT_CAN_FUNCTION_IMPORT, TYPES_TEMPLATE_FILENAME } from './module/constants'
import { generateTypeDeclaration } from './module/typegen'
import type { ModuleOptions } from './module/types'
export interface ModuleOptions { export { CONFIG_KEY }
reporter: boolean export type { ModuleOptions }
}
export const CONFIG_KEY = 'nuxtCan'
export default defineNuxtModule<ModuleOptions>({ export default defineNuxtModule<ModuleOptions>({
meta: { meta: {
name: 'nuxt-can', name: 'nuxt-can',
configKey: CONFIG_KEY, configKey: CONFIG_KEY,
compatibility: {
nuxt: '^3.0.0 || ^4.0.0',
},
}, },
defaults: { defaults: {
permissions: {},
canFunctionImport: DEFAULT_CAN_FUNCTION_IMPORT,
reporter: false, reporter: false,
}, },
async setup(options, nuxt) {
const resolver = createResolver(import.meta.url)
const permissions = options.permissions ?? {}
const canFunctionImport = options.canFunctionImport ?? DEFAULT_CAN_FUNCTION_IMPORT
const reporter = options.reporter ?? false
const publicConfig = nuxt.options.runtimeConfig.public
publicConfig[CONFIG_KEY] = {
permissions,
canFunctionImport,
}
const resolvedCanFnImport = await resolvePath(canFunctionImport, {
alias: nuxt.options.alias,
cwd: nuxt.options.srcDir,
})
addPlugin(resolver.resolve('./runtime/plugin'))
addTemplate({
filename: CAN_IMPORT_TEMPLATE_FILENAME,
getContents: () => `export { __can__ } from '${resolvedCanFnImport}'\n`,
})
addTypeTemplate({
filename: TYPES_TEMPLATE_FILENAME,
getContents: () => generateTypeDeclaration(permissions),
})
setup(options) {
addVitePlugin({ addVitePlugin({
name: 'vite-plugin-nuxt-can', name: 'vite-plugin-nuxt-can',
enforce: 'pre', enforce: 'pre',
transform(code, id) { transform(code, id) {
return transformCan({ code, id, reporter: options.reporter }) return transformCan({ code, id, reporter })
}, },
}) })
}, },

5
src/module/constants.ts Normal file
View File

@@ -0,0 +1,5 @@
export const CONFIG_KEY = 'nuxtCan'
export const CAN_IMPORT_TEMPLATE_FILENAME = 'nuxt-can/can-import.mjs'
export const TYPES_TEMPLATE_FILENAME = 'types/nuxt-can.d.ts'
export const DEFAULT_CAN_FUNCTION_IMPORT = '~/permissions/__can__'
export const INDENT = ' '

59
src/module/typegen.ts Normal file
View File

@@ -0,0 +1,59 @@
import { INDENT } from './constants'
const escapeKey = (key: string) => key.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')
const formatActions = (actions: string[]) => {
if (!actions.length) {
return `${INDENT.repeat(2)}[action: string]: boolean`
}
return actions
.map(action => `${INDENT.repeat(2)}'${escapeKey(action)}': boolean`)
.join('\n')
}
const buildPermissionTree = (permissions: Record<string, string[]>) => {
const entries = Object.entries(permissions)
if (!entries.length) {
return `any`
}
const body = entries
.map(([resource, actions]) => [
`${INDENT}'${escapeKey(resource)}': {`,
formatActions(actions),
`${INDENT}}`,
].join('\n'))
.join('\n')
return `{
${body}
}`
}
export const generateTypeDeclaration = (permissions: Record<string, string[]>) => {
const permissionTree = buildPermissionTree(permissions)
return `/* eslint-disable */
// Generated by nuxt-can do not edit manually.
type NuxtCanChecker = (path: string[]) => boolean | Promise<boolean>
type NuxtCanPermissions = ${permissionTree}
declare module 'vue' {
${INDENT}interface ComponentCustomProperties {
${INDENT.repeat(2)}can: NuxtCanPermissions
${INDENT.repeat(2)}$can: NuxtCanPermissions
${INDENT.repeat(2)}__can__: NuxtCanChecker
${INDENT}}
}
declare module '#app' {
${INDENT}interface NuxtApp {
${INDENT.repeat(2)}$can: NuxtCanPermissions
${INDENT.repeat(2)}$__can__: NuxtCanChecker
${INDENT}}
}
export {}
`
}

17
src/module/types.ts Normal file
View File

@@ -0,0 +1,17 @@
export interface ModuleOptions {
permissions?: Record<string, string[]>
/**
* Module specifier to import the host-provided __can__ function from.
* Example: '~/permissions/__can__'
*/
canFunctionImport?: string
/**
* Enable verbose template diff logging while developing the transformer.
*/
reporter?: boolean
}
export interface NuxtCanRuntimeConfig {
permissions: Record<string, string[]>
canFunctionImport: string
}

View File

@@ -1,5 +1,39 @@
import { defineNuxtPlugin } from '#app' import { defineNuxtPlugin } from '#app'
import { useRuntimeConfig } from '#imports'
import { __can__ as hostCan } from '#build/nuxt-can/can-import.mjs'
export default defineNuxtPlugin((_nuxtApp) => { import { createCanProxy } from './utils/create-can-proxy'
console.log('Plugin injected by my-module!')
interface NuxtCanRuntimeConfig {
permissions: Record<string, string[]>
canFunctionImport: string
}
type NuxtCanChecker = (path: string[]) => boolean | Promise<boolean>
export default defineNuxtPlugin((nuxtApp) => {
const runtimeConfig = useRuntimeConfig()
const moduleConfig = (runtimeConfig.public as { nuxtCan?: NuxtCanRuntimeConfig }).nuxtCan
const canProxy = createCanProxy()
const canFunction = hostCan as NuxtCanChecker
if (import.meta.dev && !moduleConfig?.canFunctionImport) {
console.warn('[nuxt-can] `canFunctionImport` is missing. Configure it via `nuxtCan.canFunctionImport` in nuxt.config.ts.')
}
nuxtApp.vueApp.config.globalProperties.can = canProxy
nuxtApp.vueApp.config.globalProperties.$can = canProxy
nuxtApp.vueApp.config.globalProperties.__can__ = canFunction
nuxtApp.provide('can', canProxy)
nuxtApp.provide('__can__', canFunction)
return {
provide: {
nuxtCan: moduleConfig ?? {
permissions: {},
canFunctionImport: '',
},
},
}
}) })

View File

@@ -3,6 +3,7 @@ import {
type TemplateChildNode, type TemplateChildNode,
type ElementNode, type ElementNode,
type DirectiveNode, type DirectiveNode,
type SourceLocation,
NodeTypes, NodeTypes,
} from '@vue/compiler-dom' } from '@vue/compiler-dom'
@@ -20,6 +21,9 @@ interface PendingCan {
expression: string expression: string
} }
const CAN_IDENTIFIER = new Set(['can', '$can'])
const SEGMENT_PATTERN = /^[\w-]+$/u
export function collectCanPatches(ast: RootNode, templateStart: number, filename?: string): Patch[] { export function collectCanPatches(ast: RootNode, templateStart: number, filename?: string): Patch[] {
const ctx: WalkerContext = { const ctx: WalkerContext = {
templateStart, templateStart,
@@ -70,12 +74,16 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
let canDirective: DirectiveNode | null = null let canDirective: DirectiveNode | null = null
let cannotDirective: DirectiveNode | null = null let cannotDirective: DirectiveNode | null = null
let ifDirective: DirectiveNode | null = null let ifDirective: DirectiveNode | null = null
let elseDirective: DirectiveNode | null = null
let elseIfDirective: DirectiveNode | null = null
for (const prop of element.props) { for (const prop of element.props) {
if (prop.type !== NodeTypes.DIRECTIVE) continue if (prop.type !== NodeTypes.DIRECTIVE) continue
if (prop.name === 'can') canDirective = prop if (prop.name === 'can') canDirective = prop
if (prop.name === 'cannot') cannotDirective = prop if (prop.name === 'cannot') cannotDirective = prop
if (prop.name === 'if') ifDirective = prop if (prop.name === 'if') ifDirective = prop
if (prop.name === 'else') elseDirective = prop
if (prop.name === 'else-if') elseIfDirective = prop
} }
if (canDirective && cannotDirective) { if (canDirective && cannotDirective) {
@@ -98,6 +106,7 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
canDirective, canDirective,
ifDirective, ifDirective,
ctx, ctx,
hasElseLike: Boolean(elseDirective || elseIfDirective),
}) })
return { return {
@@ -112,25 +121,41 @@ function transformCanDirective(params: {
canDirective: DirectiveNode canDirective: DirectiveNode
ifDirective: DirectiveNode | null ifDirective: DirectiveNode | null
ctx: WalkerContext ctx: WalkerContext
hasElseLike: boolean
}): string { }): string {
const { canDirective, ifDirective, ctx } = params const { canDirective, ifDirective, ctx, hasElseLike } = 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?.type !== NodeTypes.SIMPLE_EXPRESSION) {
raiseDirectiveError(ctx, canDirective.loc, '`v-can` expects a static expression (for example `v-can="can.x.y"`).') raiseDirectiveError(ctx, canDirective.loc, '`v-can` expects a static expression (for example `v-can="can.x.y"`).')
} }
const canExpression = canDirective.exp.content?.trim() if (canDirective.arg) {
if (!canExpression) { raiseDirectiveError(ctx, canDirective.loc, '`v-can` does not accept arguments.')
}
if (canDirective.modifiers.length) {
raiseDirectiveError(ctx, canDirective.loc, '`v-can` does not accept modifiers.')
}
const expressionContent = canDirective.exp.content?.trim()
if (!expressionContent) {
raiseDirectiveError(ctx, canDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.') raiseDirectiveError(ctx, canDirective.loc, '`v-can` requires a valid permission expression such as `can.employee.view`.')
} }
const pathSegments = parseCanExpression(expressionContent, ctx, canDirective.loc)
const canInvocation = buildCanInvocation(pathSegments)
if (ifDirective && ifDirective.exp?.type === NodeTypes.SIMPLE_EXPRESSION) { if (ifDirective && ifDirective.exp?.type === NodeTypes.SIMPLE_EXPRESSION) {
const ifExpression = (ifDirective.exp.content || 'true').trim() || 'true' const ifExpression = (ifDirective.exp.content || 'true').trim() || 'true'
ctx.patches.push({ ctx.patches.push({
start: ctx.templateStart + ifDirective.loc.start.offset, start: ctx.templateStart + ifDirective.loc.start.offset,
end: ctx.templateStart + ifDirective.loc.end.offset, end: ctx.templateStart + ifDirective.loc.end.offset,
text: `v-if="(${canExpression}) && (${ifExpression})"`, text: `v-if="(${ifExpression}) && ${canInvocation}"`,
}) })
ctx.patches.push({ ctx.patches.push({
@@ -143,11 +168,11 @@ function transformCanDirective(params: {
ctx.patches.push({ ctx.patches.push({
start: ctx.templateStart + canDirective.loc.start.offset, start: ctx.templateStart + canDirective.loc.start.offset,
end: ctx.templateStart + canDirective.loc.end.offset, end: ctx.templateStart + canDirective.loc.end.offset,
text: `v-if="${canExpression}"`, text: `v-if="${canInvocation}"`,
}) })
} }
return canExpression return canInvocation
} }
function transformCannotDirective(params: { function transformCannotDirective(params: {
@@ -184,3 +209,36 @@ function transformCannotDirective(params: {
text: `v-if="!(${pendingCan.expression})"`, text: `v-if="!(${pendingCan.expression})"`,
}) })
} }
function parseCanExpression(expression: string, ctx: WalkerContext, loc: SourceLocation): string[] {
const trimmed = expression.trim()
if (!trimmed.length) {
raiseDirectiveError(ctx, loc, '`v-can` requires a permission expression such as `can.resource.action`.')
}
const parts = trimmed.split('.')
const root = parts.shift()
const segments = parts
if (!root || !CAN_IDENTIFIER.has(root)) {
raiseDirectiveError(ctx, loc, '`v-can` expressions must start with `can.` or `$can.` (e.g. `v-can="can.employee.view"`).')
}
if (segments.length < 2) {
raiseDirectiveError(ctx, loc, '`v-can` expects at least a resource and action (for example `can.employee.view`).')
}
if (segments.some(segment => !segment.length || !SEGMENT_PATTERN.test(segment))) {
raiseDirectiveError(ctx, loc, '`v-can` only supports static dotted paths (letters, numbers, `_`, or `-`).')
}
return segments
}
function buildCanInvocation(segments: string[]): string {
const escapedSegments = segments
.map(segment => `'${segment.replace(/\\/g, '\\\\').replace(/'/g, '\\u0027')}'`)
.join(', ')
return `__can__([${escapedSegments}])`
}

View File

@@ -22,7 +22,7 @@ export function transformCan({ code, id, reporter }: TransformInput) {
const templateEnd = tpl.loc.end.offset const templateEnd = tpl.loc.end.offset
const before = code.slice(templateStart, templateEnd) const before = code.slice(templateStart, templateEnd)
if (!before.includes('v-can')) if (!before.includes('v-can') && !before.includes('v-cannot'))
return null return null
const ast = parseTemplate(before, { comments: true }) const ast = parseTemplate(before, { comments: true })
@@ -40,6 +40,12 @@ export function transformCan({ code, id, reporter }: TransformInput) {
return { return {
code: nextCode, code: nextCode,
map: null, map: {
version: 3,
sources: [id],
names: [],
mappings: '',
sourcesContent: [nextCode],
},
} }
} }

View File

@@ -0,0 +1,39 @@
const pathSymbol = Symbol('nuxt-can:path')
export interface CanPathProxy {
[segment: string]: CanPathProxy
[pathSymbol]: string[]
}
const stringifyPath = (path: string[]) => path.join('.')
export function extractCanPath(value: unknown): string[] | null {
if (value && typeof value === 'object' && pathSymbol in value) {
return (value as CanPathProxy)[pathSymbol]
}
return null
}
export function createCanProxy(path: string[] = []): CanPathProxy {
const target = () => path
return new Proxy(target, {
get(_target, prop) {
if (prop === pathSymbol) {
return path
}
if (prop === 'toString') {
return () => stringifyPath(path)
}
if (prop === 'valueOf') {
return () => path
}
if (prop === Symbol.toPrimitive) {
return () => stringifyPath(path)
}
return createCanProxy([...path, String(prop)])
},
apply() {
return path
},
}) as unknown as CanPathProxy
}

View File

@@ -7,9 +7,23 @@ describe('ssr', async () => {
rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)),
}) })
it('renders the index page', async () => { it('renders the allowed branches when permissions pass', async () => {
// Get response to a server-rendered page with `$fetch`.
const html = await $fetch('/') const html = await $fetch('/')
expect(html).toContain('<div>basic</div>') expect(html).toContain('<h1>basic</h1>')
expect(html).toContain('id="can-view"')
expect(html).toContain('id="can-edit"')
expect(html).toContain('Creation contrat')
})
it('falls back to the `v-cannot` branch when the permission is missing', async () => {
const html = await $fetch('/')
expect(html).not.toContain('id="can-delete"')
expect(html).toContain('id="cannot-delete"')
expect(html).toContain('Suppression interdite')
})
it('exposes the can proxy globally for template usage', async () => {
const html = await $fetch('/')
expect(html).toMatch(/id="path-display"[\s\S]*employee\.view/)
}) })
}) })

View File

@@ -1,6 +1,82 @@
<template> <template>
<div>basic</div> <main>
<h1>basic</h1>
<section>
<button
id="can-view"
v-can="canProxy.employee.view"
>
Voir
</button>
<button
v-if="isReady"
id="can-edit"
v-can="canProxy.employee.edit"
>
Editer
</button>
<p
id="cannot-edit"
v-cannot
>
Refus
</p>
</section>
<section>
<template v-if="showContracts">
<p v-can="canProxy.contract.create">
Creation contrat
</p>
<p v-cannot>
Pas de creation
</p>
</template>
<p v-else>
Section contrats masquee, aucune directive appliquee.
</p>
</section>
<section>
<h2>Suppression</h2>
<button
id="can-delete"
v-can="canProxy.employee.delete"
>
Supprimer
</button>
<p
id="cannot-delete"
v-cannot
>
Suppression interdite
</p>
</section>
<section>
<p id="path-display">
{{ String(canProxy.employee.view) }}
</p>
</section>
</main>
</template> </template>
<script setup> <script setup lang="ts">
const isReady = true
const showContracts = true
interface FixturePermissions {
employee: {
view: boolean
edit: boolean
delete: boolean
}
contract: {
create: boolean
}
}
const nuxtApp = useNuxtApp()
const canProxy = nuxtApp.$can as unknown as FixturePermissions
</script> </script>

View File

@@ -1,7 +1,14 @@
import MyModule from '../../../src/module' import NuxtCan from '../../../src/module'
export default defineNuxtConfig({ export default defineNuxtConfig({
modules: [ modules: [
MyModule, NuxtCan,
], ],
nuxtCan: {
permissions: {
employee: ['view', 'edit', 'delete'],
contract: ['create'],
},
canFunctionImport: '~/permissions/__can__',
},
}) })

View File

@@ -0,0 +1,10 @@
const granted = new Set([
'employee.view',
'employee.edit',
'contract.create',
])
export function __can__(path: string[]) {
const key = Array.isArray(path) ? path.join('.') : String(path)
return granted.has(key)
}

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import { transformCan } from '../src/runtime/transformer/transform-can'
const TEST_FILE = `${process.cwd()}/components/Test.vue`
const buildSFC = (template: string) => `<template>\n${template}\n</template>`
const runTransform = (template: string) => {
const result = transformCan({ code: buildSFC(template), id: TEST_FILE })
return result?.code ?? ''
}
describe('transformCan', () => {
it('injects __can__ guards and a matching v-cannot block', () => {
const code = runTransform(`
<button v-can="can.employee.view">Voir</button>
<p v-cannot>Refus</p>
`)
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', () => {
const code = runTransform(`
<div v-if="isReady" v-can="can.contract.create" />
`)
expect(code).toContain(`v-if="(isReady) && __can__(['contract', 'create'])"`)
})
it('throws when v-cannot is used without a preceding v-can', () => {
const exec = () => transformCan({
code: buildSFC('<p v-cannot>Denied</p>'),
id: TEST_FILE,
})
expect(exec).toThrow(/must immediately follow its `v-can`/)
})
it('throws when the expression does not start with can.*', () => {
const exec = () => transformCan({
code: buildSFC('<button v-can="permissions.employee.view" />'),
id: TEST_FILE,
})
expect(exec).toThrow(/expressions must start with `can\.`/)
})
it('throws when v-can is added to a v-else branch', () => {
const exec = () => transformCan({
code: buildSFC(`
<div v-if="ready"></div>
<div v-else v-can="can.employee.view"></div>
`),
id: TEST_FILE,
})
expect(exec).toThrow(/cannot be used on `v-else`/)
})
})