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

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}`,
})
}