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

View File

@@ -1,30 +1,60 @@
// modules/nuxt-can.ts
import { defineNuxtModule, addVitePlugin } from '@nuxt/kit'
import { addPlugin, addTemplate, addTypeTemplate, addVitePlugin, createResolver, defineNuxtModule, resolvePath } from '@nuxt/kit'
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 {
reporter: boolean
}
export const CONFIG_KEY = 'nuxtCan'
export { CONFIG_KEY }
export type { ModuleOptions }
export default defineNuxtModule<ModuleOptions>({
meta: {
name: 'nuxt-can',
configKey: CONFIG_KEY,
compatibility: {
nuxt: '^3.0.0 || ^4.0.0',
},
},
defaults: {
permissions: {},
canFunctionImport: DEFAULT_CAN_FUNCTION_IMPORT,
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({
name: 'vite-plugin-nuxt-can',
enforce: 'pre',
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 { useRuntimeConfig } from '#imports'
import { __can__ as hostCan } from '#build/nuxt-can/can-import.mjs'
export default defineNuxtPlugin((_nuxtApp) => {
console.log('Plugin injected by my-module!')
import { createCanProxy } from './utils/create-can-proxy'
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 ElementNode,
type DirectiveNode,
type SourceLocation,
NodeTypes,
} from '@vue/compiler-dom'
@@ -20,6 +21,9 @@ interface PendingCan {
expression: string
}
const CAN_IDENTIFIER = new Set(['can', '$can'])
const SEGMENT_PATTERN = /^[\w-]+$/u
export function collectCanPatches(ast: RootNode, templateStart: number, filename?: string): Patch[] {
const ctx: WalkerContext = {
templateStart,
@@ -70,12 +74,16 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
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
}
if (canDirective && cannotDirective) {
@@ -98,6 +106,7 @@ function handleElement(element: ElementNode, ctx: WalkerContext, pendingCan: Pen
canDirective,
ifDirective,
ctx,
hasElseLike: Boolean(elseDirective || elseIfDirective),
})
return {
@@ -112,25 +121,41 @@ function transformCanDirective(params: {
canDirective: DirectiveNode
ifDirective: DirectiveNode | null
ctx: WalkerContext
hasElseLike: boolean
}): 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) {
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) {
if (canDirective.arg) {
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`.')
}
const pathSegments = parseCanExpression(expressionContent, ctx, canDirective.loc)
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="(${canExpression}) && (${ifExpression})"`,
text: `v-if="(${ifExpression}) && ${canInvocation}"`,
})
ctx.patches.push({
@@ -143,11 +168,11 @@ function transformCanDirective(params: {
ctx.patches.push({
start: ctx.templateStart + canDirective.loc.start.offset,
end: ctx.templateStart + canDirective.loc.end.offset,
text: `v-if="${canExpression}"`,
text: `v-if="${canInvocation}"`,
})
}
return canExpression
return canInvocation
}
function transformCannotDirective(params: {
@@ -184,3 +209,36 @@ function transformCannotDirective(params: {
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 before = code.slice(templateStart, templateEnd)
if (!before.includes('v-can'))
if (!before.includes('v-can') && !before.includes('v-cannot'))
return null
const ast = parseTemplate(before, { comments: true })
@@ -40,6 +40,12 @@ export function transformCan({ code, id, reporter }: TransformInput) {
return {
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
}