feat: add directive transformer runtime
This commit is contained in:
@@ -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 })
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
30
src/runtime/transformer/errors.ts
Normal file
30
src/runtime/transformer/errors.ts
Normal 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
|
||||
}
|
||||
5
src/runtime/transformer/nodes.ts
Normal file
5
src/runtime/transformer/nodes.ts
Normal 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
|
||||
}
|
||||
7
src/runtime/transformer/patcher.ts
Normal file
7
src/runtime/transformer/patcher.ts
Normal 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)
|
||||
}
|
||||
186
src/runtime/transformer/patches.ts
Normal file
186
src/runtime/transformer/patches.ts
Normal 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})"`,
|
||||
})
|
||||
}
|
||||
19
src/runtime/transformer/reporter.ts
Normal file
19
src/runtime/transformer/reporter.ts
Normal 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'))
|
||||
}
|
||||
45
src/runtime/transformer/transform-can.ts
Normal file
45
src/runtime/transformer/transform-can.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
11
src/runtime/transformer/types.ts
Normal file
11
src/runtime/transformer/types.ts
Normal 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
10
src/runtime/utils.ts
Normal 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}`,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user