feat: wire global runtime injection and playground
This commit is contained in:
@@ -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
5
src/module/constants.ts
Normal 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
59
src/module/typegen.ts
Normal 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
17
src/module/types.ts
Normal 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
|
||||
}
|
||||
@@ -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: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}])`
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
39
src/runtime/utils/create-can-proxy.ts
Normal file
39
src/runtime/utils/create-can-proxy.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user