From 0d89115212c4a9f10e485f76a3ff9401b6dab46e Mon Sep 17 00:00:00 2001 From: KazariEX Date: Wed, 17 Jun 2026 11:21:19 +0800 Subject: [PATCH 1/4] feat: support in-tag comments --- .../compiler-core/__tests__/parse.spec.ts | 65 +++++++++++++++++++ packages/compiler-core/src/ast.ts | 11 +++- .../src/compat/transformFilter.ts | 4 +- packages/compiler-core/src/parser.ts | 24 +++++-- packages/compiler-core/src/tokenizer.ts | 55 +++++++++++++++- .../src/transforms/transformElement.ts | 6 +- .../src/transforms/transformSlotOutlet.ts | 2 +- packages/compiler-core/src/utils.ts | 4 +- .../src/template/transformSrcset.ts | 2 +- .../src/transforms/ssrTransformElement.ts | 5 +- .../compiler-ssr/src/transforms/ssrVModel.ts | 6 +- 11 files changed, 165 insertions(+), 19 deletions(-) diff --git a/packages/compiler-core/__tests__/parse.spec.ts b/packages/compiler-core/__tests__/parse.spec.ts index 8d32981524b..7e574791cb8 100644 --- a/packages/compiler-core/__tests__/parse.spec.ts +++ b/packages/compiler-core/__tests__/parse.spec.ts @@ -6,6 +6,7 @@ import { type DirectiveNode, type ElementNode, ElementTypes, + type InTagCommentNode, type InterpolationNode, Namespaces, NodeTypes, @@ -1171,6 +1172,70 @@ describe('compiler: parse', () => { }) }) + test('in-tag comments', () => { + const ast = baseParse(` + :selected-id="selectedId" + + disabled + @click="onClick" + + />`) + const element = ast.children[0] as ElementNode + const props = element.props + + expect(element.children).toStrictEqual([]) + expect(props.map(p => p.type)).toStrictEqual([ + NodeTypes.IN_TAG_COMMENT, + NodeTypes.DIRECTIVE, + NodeTypes.IN_TAG_COMMENT, + NodeTypes.ATTRIBUTE, + NodeTypes.DIRECTIVE, + NodeTypes.IN_TAG_COMMENT, + ]) + + expect(props[0]).toMatchObject({ + type: NodeTypes.IN_TAG_COMMENT, + content: ' @vue-expect-error ', + loc: { + source: '', + }, + }) + expect(props[2]).toMatchObject({ + type: NodeTypes.IN_TAG_COMMENT, + content: ' note ', + }) + expect(props[5]).toMatchObject({ + type: NodeTypes.IN_TAG_COMMENT, + content: ' tail ', + }) + }) + + test('in-tag comment can directly follow tag name', () => { + const ast = baseParse('>') + const element = ast.children[0] as ElementNode + + expect(element.tag).toBe('div') + expect(element.props).toHaveLength(1) + const comment = element.props[0] as InTagCommentNode + expect(comment).toMatchObject({ + type: NodeTypes.IN_TAG_COMMENT, + content: ' note ', + loc: { + source: '', + }, + }) + expect(element.children).toStrictEqual([]) + }) + + test('in-tag comment emits EOF_IN_COMMENT when unterminated', () => { + const onError = vi.fn() + baseParse('
{ const ast = baseParse('
') diff --git a/packages/compiler-core/src/ast.ts b/packages/compiler-core/src/ast.ts index 520a43c93b0..ba1159e63d7 100644 --- a/packages/compiler-core/src/ast.ts +++ b/packages/compiler-core/src/ast.ts @@ -58,6 +58,8 @@ export enum NodeTypes { JS_ASSIGNMENT_EXPRESSION, JS_SEQUENCE_EXPRESSION, JS_RETURN_STATEMENT, + + IN_TAG_COMMENT, } export enum ElementTypes { @@ -131,7 +133,7 @@ export interface BaseElementNode extends Node { ns: Namespace tag: string tagType: ElementTypes - props: Array + props: ElementPropNode[] children: TemplateChildNode[] isSelfClosing?: boolean innerLoc?: SourceLocation // only for SFC root level elements @@ -210,6 +212,13 @@ export interface DirectiveNode extends Node { forParseResult?: ForParseResult } +export interface InTagCommentNode extends Node { + type: NodeTypes.IN_TAG_COMMENT + content: string +} + +export type ElementPropNode = AttributeNode | DirectiveNode | InTagCommentNode + /** * Static types have several levels. * Higher levels implies lower levels. e.g. a node that can be stringified diff --git a/packages/compiler-core/src/compat/transformFilter.ts b/packages/compiler-core/src/compat/transformFilter.ts index 4791e67543e..a80cdf7b8bd 100644 --- a/packages/compiler-core/src/compat/transformFilter.ts +++ b/packages/compiler-core/src/compat/transformFilter.ts @@ -1,7 +1,5 @@ import { RESOLVE_FILTER } from '../runtimeHelpers' import { - type AttributeNode, - type DirectiveNode, type ExpressionNode, NodeTypes, type SimpleExpressionNode, @@ -26,7 +24,7 @@ export const transformFilter: NodeTransform = (node, context) => { // simple expressions are possible at this stage rewriteFilter(node.content, context) } else if (node.type === NodeTypes.ELEMENT) { - node.props.forEach((prop: AttributeNode | DirectiveNode) => { + node.props.forEach(prop => { if ( prop.type === NodeTypes.DIRECTIVE && prop.name !== 'for' && diff --git a/packages/compiler-core/src/parser.ts b/packages/compiler-core/src/parser.ts index b4c017edd83..c754219b578 100644 --- a/packages/compiler-core/src/parser.ts +++ b/packages/compiler-core/src/parser.ts @@ -301,8 +301,10 @@ const tokenizer = new Tokenizer(stack, { } // check duplicate attrs if ( - currentOpenTag!.props.some( - p => (p.type === NodeTypes.DIRECTIVE ? p.rawName : p.name) === name, + currentOpenTag!.props.some(p => + p.type === NodeTypes.ATTRIBUTE + ? p.name === name + : p.type === NodeTypes.DIRECTIVE && p.rawName === name, ) ) { emitError(ErrorCodes.DUPLICATE_ATTRIBUTE, start) @@ -419,6 +421,14 @@ const tokenizer = new Tokenizer(stack, { } }, + onintagcomment(start, end) { + currentOpenTag!.props.push({ + type: NodeTypes.IN_TAG_COMMENT, + content: getSlice(start, end), + loc: getLoc(start - 4, end + 3), + }) + }, + onend() { const end = currentInput.length // EOF ERRORS @@ -442,6 +452,9 @@ const tokenizer = new Tokenizer(stack, { emitError(ErrorCodes.EOF_IN_COMMENT, end) } break + case State.InTagComment: + emitError(ErrorCodes.EOF_IN_COMMENT, end) + break case State.InTagName: case State.InSelfClosingTag: case State.InClosingTagName: @@ -768,10 +781,8 @@ const specialTemplateDir = new Set(['if', 'else', 'else-if', 'for', 'slot']) function isFragmentTemplate({ tag, props }: ElementNode): boolean { if (tag === 'template') { for (let i = 0; i < props.length; i++) { - if ( - props[i].type === NodeTypes.DIRECTIVE && - specialTemplateDir.has((props[i] as DirectiveNode).name) - ) { + const p = props[i] + if (p.type === NodeTypes.DIRECTIVE && specialTemplateDir.has(p.name)) { return true } } @@ -814,6 +825,7 @@ function isComponent({ tag, props }: ElementNode): boolean { } } else if ( __COMPAT__ && + p.type === NodeTypes.DIRECTIVE && // :is on plain element - only treat as component in compat mode p.name === 'bind' && isStaticArgOf(p.arg, 'is') && diff --git a/packages/compiler-core/src/tokenizer.ts b/packages/compiler-core/src/tokenizer.ts index c6084e1cac0..8772aaaf4ad 100644 --- a/packages/compiler-core/src/tokenizer.ts +++ b/packages/compiler-core/src/tokenizer.ts @@ -125,6 +125,7 @@ export enum State { CDATASequence, InSpecialComment, InCommentLike, + InTagComment, // Special tags BeforeSpecialS, // Decide if we deal with ` this.sectionStart) { @@ -405,7 +417,7 @@ export default class Tokenizer { const isEnd = this.sequenceIndex === this.currentSequence.length const isMatch = isEnd ? // If we are at the end of the sequence, make sure the tag name has ended - isEndOfTagSection(c) + isEndOfTagSection(c) || this.isInTagCommentOpen() : // Otherwise, do a case-insensitive comparison (c | 0x20) === this.currentSequence[this.sequenceIndex] @@ -545,6 +557,31 @@ export default class Tokenizer { } } + private startInTagComment(): void { + this.state = State.InTagComment + this.currentSequence = Sequences.CommentEnd + this.sequenceIndex = 0 + this.sectionStart = this.index + 4 + this.index += 3 + } + + private stateInTagComment(c: number): void { + if (c === this.currentSequence[this.sequenceIndex]) { + if (++this.sequenceIndex === this.currentSequence.length) { + this.cbs.onintagcomment(this.sectionStart, this.index - 2) + this.sequenceIndex = 0 + this.sectionStart = this.index + 1 + this.state = State.BeforeAttrName + } + } else if (this.sequenceIndex === 0) { + if (this.fastForwardTo(this.currentSequence[0])) { + this.sequenceIndex = 1 + } + } else if (c !== this.currentSequence[this.sequenceIndex - 1]) { + this.sequenceIndex = 0 + } + } + private startSpecial(sequence: Uint8Array, offset: number) { this.enterRCDATA(sequence, offset) this.state = State.SpecialStartSequence @@ -594,7 +631,10 @@ export default class Tokenizer { } } private stateInTagName(c: number): void { - if (isEndOfTagSection(c)) { + if (this.isInTagCommentOpen()) { + this.cbs.onopentagname(this.sectionStart, this.index) + this.startInTagComment() + } else if (isEndOfTagSection(c)) { this.handleTagName(c) } } @@ -666,6 +706,8 @@ export default class Tokenizer { this.cbs.onopentagend(this.index) this.state = State.BeforeTagName this.sectionStart = this.index + } else if (this.isInTagCommentOpen()) { + this.startInTagComment() } else if (!isWhitespace(c)) { if ((__DEV__ || !__BROWSER__) && c === CharCodes.Eq) { this.cbs.onerr( @@ -784,6 +826,9 @@ export default class Tokenizer { this.sectionStart = -1 this.state = State.BeforeAttrName this.stateBeforeAttrName(c) + } else if (this.isInTagCommentOpen()) { + this.cbs.onattribend(QuoteType.NoValue, this.sectionStart) + this.startInTagComment() } else if (!isWhitespace(c)) { this.cbs.onattribend(QuoteType.NoValue, this.sectionStart) this.handleAttrStart(c) @@ -1005,6 +1050,10 @@ export default class Tokenizer { this.stateInCommentLike(c) break } + case State.InTagComment: { + this.stateInTagComment(c) + break + } case State.InSpecialComment: { this.stateInSpecialComment(c) break @@ -1141,6 +1190,8 @@ export default class Tokenizer { } else { this.cbs.oncomment(this.sectionStart, endIndex) } + } else if (this.state === State.InTagComment) { + // Let onend report EOF_IN_COMMENT without emitting an incomplete prop. } else if ( this.state === State.InTagName || this.state === State.BeforeAttrName || diff --git a/packages/compiler-core/src/transforms/transformElement.ts b/packages/compiler-core/src/transforms/transformElement.ts index 1dca0c514c1..5418cc1fdf9 100644 --- a/packages/compiler-core/src/transforms/transformElement.ts +++ b/packages/compiler-core/src/transforms/transformElement.ts @@ -488,7 +488,9 @@ export function buildProps( for (let i = 0; i < props.length; i++) { // static attribute const prop = props[i] - if (prop.type === NodeTypes.ATTRIBUTE) { + if (prop.type === NodeTypes.IN_TAG_COMMENT) { + continue + } else if (prop.type === NodeTypes.ATTRIBUTE) { const { loc, name, nameLoc, value } = prop let isStatic = true if (name === 'ref') { @@ -537,7 +539,7 @@ export function buildProps( ), ), ) - } else { + } else if (prop.type === NodeTypes.DIRECTIVE) { // directives const { name, arg, exp, loc, modifiers } = prop const isVBind = name === 'bind' diff --git a/packages/compiler-core/src/transforms/transformSlotOutlet.ts b/packages/compiler-core/src/transforms/transformSlotOutlet.ts index ea635e997b1..bb4b4732207 100644 --- a/packages/compiler-core/src/transforms/transformSlotOutlet.ts +++ b/packages/compiler-core/src/transforms/transformSlotOutlet.ts @@ -76,7 +76,7 @@ export function processSlotOutlet( nonNameProps.push(p) } } - } else { + } else if (p.type === NodeTypes.DIRECTIVE) { if (p.name === 'bind' && isStaticArgOf(p.arg, 'name')) { if (p.exp) { slotName = p.exp diff --git a/packages/compiler-core/src/utils.ts b/packages/compiler-core/src/utils.ts index aa426bbab47..0c4a4bc9873 100644 --- a/packages/compiler-core/src/utils.ts +++ b/packages/compiler-core/src/utils.ts @@ -1,4 +1,5 @@ import { + type AttributeNode, type BlockCodegenNode, type CacheExpression, type CallExpression, @@ -302,7 +303,7 @@ export function findProp( name: string, dynamicOnly: boolean = false, allowEmpty: boolean = false, -): ElementNode['props'][0] | undefined { +): AttributeNode | DirectiveNode | undefined { for (let i = 0; i < node.props.length; i++) { const p = node.props[i] if (p.type === NodeTypes.ATTRIBUTE) { @@ -311,6 +312,7 @@ export function findProp( return p } } else if ( + p.type === NodeTypes.DIRECTIVE && p.name === 'bind' && (p.exp || allowEmpty) && isStaticArgOf(p.arg, name) diff --git a/packages/compiler-sfc/src/template/transformSrcset.ts b/packages/compiler-sfc/src/template/transformSrcset.ts index 6413f70a8b0..01d31389fee 100644 --- a/packages/compiler-sfc/src/template/transformSrcset.ts +++ b/packages/compiler-sfc/src/template/transformSrcset.ts @@ -45,7 +45,7 @@ export const transformSrcset: NodeTransform = ( if (node.type === NodeTypes.ELEMENT) { if (srcsetTags.includes(node.tag) && node.props.length) { node.props.forEach((attr, index) => { - if (attr.name === 'srcset' && attr.type === NodeTypes.ATTRIBUTE) { + if (attr.type === NodeTypes.ATTRIBUTE && attr.name === 'srcset') { if (!attr.value) return const value = attr.value.content if (!value) return diff --git a/packages/compiler-ssr/src/transforms/ssrTransformElement.ts b/packages/compiler-ssr/src/transforms/ssrTransformElement.ts index 3fbedc1ae57..daf3da86154 100644 --- a/packages/compiler-ssr/src/transforms/ssrTransformElement.ts +++ b/packages/compiler-ssr/src/transforms/ssrTransformElement.ts @@ -221,6 +221,9 @@ export const ssrTransformElement: NodeTransform = (node, context) => { for (let i = 0; i < node.props.length; i++) { const prop = node.props[i] + if (prop.type === NodeTypes.IN_TAG_COMMENT) { + continue + } // ignore true-value/false-value on input if (node.tag === 'input' && isTrueFalseValue(prop)) { continue @@ -336,7 +339,7 @@ export const ssrTransformElement: NodeTransform = (node, context) => { } } } - } else { + } else if (prop.type === NodeTypes.ATTRIBUTE) { // special case: value on