From f12bc0ce85ef1c20d4f7fb9b4e0e8b172e2728d3 Mon Sep 17 00:00:00 2001 From: stanig2106 Date: Fri, 14 Nov 2025 06:15:33 +0100 Subject: [PATCH] feat: wire global runtime injection and playground --- nuxt.d.ts | 3 + package.json | 3 +- playground/app.vue | 76 +++++++++++++++----- playground/nuxt.config.ts | 11 +-- playground/permissions/__can__.ts | 6 +- src/module.ts | 50 +++++++++++--- src/module/constants.ts | 5 ++ src/module/typegen.ts | 59 ++++++++++++++++ src/module/types.ts | 17 +++++ src/runtime/plugin.ts | 38 +++++++++- src/runtime/transformer/patches.ts | 70 +++++++++++++++++-- src/runtime/transformer/transform-can.ts | 10 ++- src/runtime/utils/create-can-proxy.ts | 39 +++++++++++ test/basic.test.ts | 20 +++++- test/fixtures/basic/app.vue | 80 +++++++++++++++++++++- test/fixtures/basic/nuxt.config.ts | 11 ++- test/fixtures/basic/permissions/__can__.ts | 10 +++ test/transform-can.test.ts | 62 +++++++++++++++++ 18 files changed, 516 insertions(+), 54 deletions(-) create mode 100644 nuxt.d.ts create mode 100644 src/module/constants.ts create mode 100644 src/module/typegen.ts create mode 100644 src/module/types.ts create mode 100644 src/runtime/utils/create-can-proxy.ts create mode 100644 test/fixtures/basic/permissions/__can__.ts create mode 100644 test/transform-can.test.ts diff --git a/nuxt.d.ts b/nuxt.d.ts new file mode 100644 index 0000000..2402f0d --- /dev/null +++ b/nuxt.d.ts @@ -0,0 +1,3 @@ +import './types/shims-nuxt-can.d.ts' + +export {} diff --git a/package.json b/package.json index 69e944e..b99606c 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,7 @@ "dependencies": { "@nuxt/kit": "^4.2.1", "@vue/language-core": "^3.1.3", - "diff": "^8.0.2", - "htmlparser2": "^10.0.0" + "diff": "^8.0.2" }, "devDependencies": { "@nuxt/devtools": "^3.1.0", diff --git a/playground/app.vue b/playground/app.vue index ce77ab7..fa2fc48 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -1,24 +1,15 @@ diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 1c5bb9f..5eb61af 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -2,11 +2,12 @@ export default defineNuxtConfig({ modules: ['../src/module'], devtools: { enabled: true }, nuxtCan: { - reporter: true - // permissions: { - // employee: ['view', 'edit'], - // contract: ['create'], - // }, + reporter: true, + permissions: { + employee: ['view', 'edit'], + contract: ['create'], + baz: [], + }, // canFunctionImport: '~/permissions/__can__', }, }) diff --git a/playground/permissions/__can__.ts b/playground/permissions/__can__.ts index 4e411b3..bab40ca 100644 --- a/playground/permissions/__can__.ts +++ b/playground/permissions/__can__.ts @@ -6,7 +6,7 @@ export function __can__(path: string[]) { 'contract.create', ]) - // return allowed.has(key) - console.log('Checking permission:', key) - return false + const granted = allowed.has(key) + console.log('Checking permission:', key, '->', granted) + return granted } diff --git a/src/module.ts b/src/module.ts index 10b6de6..0112347 100644 --- a/src/module.ts +++ b/src/module.ts @@ -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({ 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 }) }, }) }, diff --git a/src/module/constants.ts b/src/module/constants.ts new file mode 100644 index 0000000..70cdba8 --- /dev/null +++ b/src/module/constants.ts @@ -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 = ' ' diff --git a/src/module/typegen.ts b/src/module/typegen.ts new file mode 100644 index 0000000..3ee6587 --- /dev/null +++ b/src/module/typegen.ts @@ -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) => { + 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) => { + const permissionTree = buildPermissionTree(permissions) + + return `/* eslint-disable */ +// Generated by nuxt-can – do not edit manually. +type NuxtCanChecker = (path: string[]) => boolean | Promise +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 {} +` +} diff --git a/src/module/types.ts b/src/module/types.ts new file mode 100644 index 0000000..31fd6e8 --- /dev/null +++ b/src/module/types.ts @@ -0,0 +1,17 @@ +export interface ModuleOptions { + permissions?: Record + /** + * 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 + canFunctionImport: string +} diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index df7fd87..3db9f38 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -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 + canFunctionImport: string +} + +type NuxtCanChecker = (path: string[]) => boolean | Promise + +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: '', + }, + }, + } }) diff --git a/src/runtime/transformer/patches.ts b/src/runtime/transformer/patches.ts index 8d067a6..ff9752d 100644 --- a/src/runtime/transformer/patches.ts +++ b/src/runtime/transformer/patches.ts @@ -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