diff --git a/package.json b/package.json index 72dc513..28303d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matters/matters-editor", - "version": "0.2.5-alpha.4", + "version": "0.2.5-alpha.5", "description": "Editor for matters.news", "author": "https://github.com/thematters", "homepage": "https://github.com/thematters/matters-editor", diff --git a/src/editors/extensions/link/helpers/autolink.ts b/src/editors/extensions/link/helpers/autolink.ts index 15d771c..23c7037 100644 --- a/src/editors/extensions/link/helpers/autolink.ts +++ b/src/editors/extensions/link/helpers/autolink.ts @@ -3,78 +3,79 @@ import { findChildrenInRange, getChangedRanges, getMarksBetween, - type NodeWithPos, + NodeWithPos, } from '@tiptap/core' -import { type MarkType } from '@tiptap/pm/model' +import { MarkType } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' -import { find, test } from 'linkifyjs' +import { MultiToken, tokenize } from 'linkifyjs' + +/** + * Check if the provided tokens form a valid link structure, which can either be a single link token + * or a link token surrounded by parentheses or square brackets. + * + * This ensures that only complete and valid text is hyperlinked, preventing cases where a valid + * top-level domain (TLD) is immediately followed by an invalid character, like a number. For + * example, with the `find` method from Linkify, entering `example.com1` would result in + * `example.com` being linked and the trailing `1` left as plain text. By using the `tokenize` + * method, we can perform more comprehensive validation on the input text. + */ +function isValidLinkStructure( + tokens: Array>, +) { + if (tokens.length === 1) { + return tokens[0].isLink + } + + if (tokens.length === 3 && tokens[1].isLink) { + return ['()', '[]'].includes(tokens[0].value + tokens[2].value) + } + + return false +} -interface AutolinkOptions { +type AutolinkOptions = { type: MarkType - validate?: (url: string) => boolean + defaultProtocol: string + validate: (url: string) => boolean } +/** + * This plugin allows you to automatically add links to your editor. + * @param options The plugin options + * @returns The plugin instance + */ export function autolink(options: AutolinkOptions): Plugin { return new Plugin({ key: new PluginKey('autolink'), appendTransaction: (transactions, oldState, newState) => { + /** + * Does the transaction change the document? + */ const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc) + + /** + * Prevent autolink if the transaction is not a document change or if the transaction has the meta `preventAutolink`. + */ const preventAutolink = transactions.some((transaction) => transaction.getMeta('preventAutolink'), ) + /** + * Prevent autolink if the transaction is not a document change + * or if the transaction has the meta `preventAutolink`. + */ if (!docChanges || preventAutolink) { return } const { tr } = newState const transform = combineTransactionSteps(oldState.doc, [...transactions]) - const { mapping } = transform const changes = getChangedRanges(transform) - changes.forEach(({ oldRange, newRange }) => { - // at first we check if we have to remove links - getMarksBetween(oldRange.from, oldRange.to, oldState.doc) - .filter((item) => item.mark.type === options.type) - .forEach((oldMark) => { - const newFrom = mapping.map(oldMark.from) - const newTo = mapping.map(oldMark.to) - const newMarks = getMarksBetween( - newFrom, - newTo, - newState.doc, - ).filter((item) => item.mark.type === options.type) - - if (newMarks.length === 0) { - return - } - - const newMark = newMarks[0] - const oldLinkText = oldState.doc.textBetween( - oldMark.from, - oldMark.to, - undefined, - ' ', - ) - const newLinkText = newState.doc.textBetween( - newMark.from, - newMark.to, - undefined, - ' ', - ) - const wasLink = test(oldLinkText) - const isLink = test(newLinkText) - - // remove only the link, if it was a link before too - // because we don’t want to remove links that were set manually - if (wasLink && !isLink) { - tr.removeMark(newMark.from, newMark.to, options.type) - } - }) - - // now let’s see if we can add new links + changes.forEach(({ newRange }) => { + // Now let’s see if we can add new links. const nodesInChangedRanges = findChildrenInRange( newState.doc, newRange, @@ -85,7 +86,7 @@ export function autolink(options: AutolinkOptions): Plugin { let textBeforeWhitespace: string | undefined if (nodesInChangedRanges.length > 1) { - // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter) + // Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter). textBlock = nodesInChangedRanges[0] textBeforeWhitespace = newState.doc.textBetween( textBlock.pos, @@ -94,8 +95,8 @@ export function autolink(options: AutolinkOptions): Plugin { ' ', ) } else if ( - nodesInChangedRanges.length > 0 && - // We want to make sure to include the block seperator argument to treat hard breaks like spaces + nodesInChangedRanges.length && + // We want to make sure to include the block seperator argument to treat hard breaks like spaces. newState.doc .textBetween(newRange.from, newRange.to, ' ', ' ') .endsWith(' ') @@ -128,22 +129,46 @@ export function autolink(options: AutolinkOptions): Plugin { return false } - find(lastWordBeforeSpace) + const linksBeforeSpace = tokenize(lastWordBeforeSpace).map((t) => + t.toObject(options.defaultProtocol), + ) + + if (!isValidLinkStructure(linksBeforeSpace)) { + return false + } + + linksBeforeSpace .filter((link) => link.isLink) - .filter((link) => { - if (options.validate) { - return options.validate(link.value) - } - return true - }) - // calculate link position + // Calculate link position. .map((link) => ({ ...link, from: lastWordAndBlockOffset + link.start + 1, to: lastWordAndBlockOffset + link.end + 1, })) - // add link mark + // ignore link inside code mark + .filter((link) => { + if (!newState.schema.marks.code) { + return true + } + + return !newState.doc.rangeHasMark( + link.from, + link.to, + newState.schema.marks.code, + ) + }) + // validate link + .filter((link) => options.validate(link.value)) + // Add link mark. .forEach((link) => { + if ( + getMarksBetween(link.from, link.to, newState.doc).some( + (item) => item.mark.type === options.type, + ) + ) { + return + } + tr.addMark( link.from, link.to, @@ -155,7 +180,7 @@ export function autolink(options: AutolinkOptions): Plugin { } }) - if (tr.steps.length === 0) { + if (!tr.steps.length) { return } diff --git a/src/editors/extensions/link/helpers/clickHandler.ts b/src/editors/extensions/link/helpers/clickHandler.ts index 6307416..dbbffdb 100644 --- a/src/editors/extensions/link/helpers/clickHandler.ts +++ b/src/editors/extensions/link/helpers/clickHandler.ts @@ -1,8 +1,8 @@ import { getAttributes } from '@tiptap/core' -import { type MarkType } from '@tiptap/pm/model' +import { MarkType } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' -interface ClickHandlerOptions { +type ClickHandlerOptions = { type: MarkType } @@ -11,15 +11,27 @@ export function clickHandler(options: ClickHandlerOptions): Plugin { key: new PluginKey('handleClickLink'), props: { handleClick: (view, pos, event) => { - if (event.button !== 1) { + if (event.button !== 0) { + return false + } + + let a = event.target as HTMLElement + const els = [] + + while (a.nodeName !== 'DIV') { + els.push(a) + a = a.parentNode as HTMLElement + } + + if (!els.find((value) => value.nodeName === 'A')) { return false } const attrs = getAttributes(view.state, options.type.name) - const link = (event.target as HTMLElement)?.closest('a') + const link = event.target as HTMLLinkElement - const href = link?.href ?? (attrs.href as string) - const target = link?.target ?? (attrs.target as string) + const href = link?.href ?? attrs.href + const target = link?.target ?? attrs.target if (link && href) { window.open(href, target) diff --git a/src/editors/extensions/link/helpers/pasteHandler.ts b/src/editors/extensions/link/helpers/pasteHandler.ts index 3fe7c84..5ad7c6f 100644 --- a/src/editors/extensions/link/helpers/pasteHandler.ts +++ b/src/editors/extensions/link/helpers/pasteHandler.ts @@ -1,10 +1,11 @@ -import { type Editor } from '@tiptap/core' -import { type MarkType } from '@tiptap/pm/model' +import { Editor } from '@tiptap/core' +import { MarkType } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' import { find } from 'linkifyjs' -interface PasteHandlerOptions { +type PasteHandlerOptions = { editor: Editor + defaultProtocol: string type: MarkType } @@ -27,9 +28,9 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { textContent += node.textContent }) - const link = find(textContent).find( - (item) => item.isLink && item.value === textContent, - ) + const link = find(textContent, { + defaultProtocol: options.defaultProtocol, + }).find((item) => item.isLink && item.value === textContent) if (!textContent || !link) { return false diff --git a/src/editors/extensions/link/index.ts b/src/editors/extensions/link/index.ts index 9e10815..82d03f0 100644 --- a/src/editors/extensions/link/index.ts +++ b/src/editors/extensions/link/index.ts @@ -1,5 +1,10 @@ -import { Mark, markPasteRule, mergeAttributes } from '@tiptap/core' -import { type Plugin } from '@tiptap/pm/state' +import { + Mark, + markPasteRule, + mergeAttributes, + PasteRuleMatch, +} from '@tiptap/core' +import { Plugin } from '@tiptap/pm/state' import { find, registerCustomProtocol, reset } from 'linkifyjs' import { autolink } from './helpers/autolink' @@ -11,44 +16,80 @@ import { pasteHandler } from './helpers/pasteHandler' * * @see {@url https://github.com/ueberdosis/tiptap/tree/main/packages/extension-link} * - * The only changes are we altered `parseHTML.tag` to - * resolve the conflict with Mention extension - * since the priority doesn't work as expected. + * Differences: + * - altered `parseHTML.tag` to resolve the conflict with Mention extension + * since the priority doesn't work as expected. + * - clamp long text */ export interface LinkProtocolOptions { + /** + * The protocol scheme to be registered. + * @default ''' + * @example 'ftp' + * @example 'git' + */ scheme: string + + /** + * If enabled, it allows optional slashes after the protocol. + * @default false + * @example true + */ optionalSlashes?: boolean } +export const pasteRegex = + /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi + export interface LinkOptions { /** - * If enabled, it adds links as you type. + * If enabled, the extension will automatically add links as you type. + * @default true + * @example false */ autolink: boolean + /** * An array of custom protocols to be registered with linkifyjs. + * @default [] + * @example ['ftp', 'git'] */ protocols: Array + + /** + * Default protocol to use when no protocol is specified. + * @default 'http' + */ + defaultProtocol: string /** * If enabled, links will be opened on click. + * @default true + * @example false + * @example 'whenNotEditable' */ openOnClick: boolean /** * Adds a link to the current selection if the pasted content only contains an url. + * @default true + * @example false */ linkOnPaste: boolean + /** - * A list of HTML attributes to be rendered. + * HTML attributes to add to the link element. + * @default {} + * @example { class: 'foo' } */ // eslint-disable-next-line @typescript-eslint/no-explicit-any HTMLAttributes: Record + /** * A validation function that modifies link verification for the auto linker. * @param url - The url to be validated. * @returns - True if the url is valid, false otherwise. */ - validate?: (url: string) => boolean + validate: (url: string) => boolean } declare module '@tiptap/core' { @@ -56,31 +97,59 @@ declare module '@tiptap/core' { link: { /** * Set a link mark + * @param attributes The link attributes + * @example editor.commands.setLink({ href: 'https://tiptap.dev' }) */ setLink: (attributes: { href: string target?: string | null + rel?: string | null + class?: string | null }) => ReturnType /** * Toggle a link mark + * @param attributes The link attributes + * @example editor.commands.toggleLink({ href: 'https://tiptap.dev' }) */ toggleLink: (attributes: { href: string target?: string | null + rel?: string | null + class?: string | null }) => ReturnType /** * Unset a link mark + * @example editor.commands.unsetLink() */ unsetLink: () => ReturnType } } } +// From DOMPurify +// https://github.com/cure53/DOMPurify/blob/main/src/regexp.js +const ATTR_WHITESPACE = + /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex +const IS_ALLOWED_URI = + /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape + +function isAllowedUri(uri: string | undefined) { + return !uri || uri.replace(ATTR_WHITESPACE, '').match(IS_ALLOWED_URI) +} + +/** + * This extension allows you to create links. + * @see https://www.tiptap.dev/api/marks/link + */ export const Link = Mark.create({ name: 'link', + priority: 1000, + keepOnSplit: false, + exitable: true, + onCreate() { this.options.protocols.forEach((protocol) => { if (typeof protocol === 'string') { @@ -105,11 +174,13 @@ export const Link = Mark.create({ linkOnPaste: true, autolink: true, protocols: [], + defaultProtocol: 'http', HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow', + class: null, }, - validate: undefined, + validate: (url) => !!url, } }, @@ -121,18 +192,46 @@ export const Link = Mark.create({ target: { default: this.options.HTMLAttributes.target, }, + rel: { + default: this.options.HTMLAttributes.rel, + }, + class: { + default: this.options.HTMLAttributes.class, + }, } }, parseHTML() { return [ { - tag: 'a[href]:not([href *= "javascript:" i]):not([class="mention"])', + tag: 'a[href]:not([class="mention"])', + getAttrs: (dom) => { + const href = (dom as HTMLElement).getAttribute('href') + + // prevent XSS attacks + if (!href || !isAllowedUri(href)) { + return false + } + return { href } + }, }, ] }, renderHTML({ HTMLAttributes }) { + // prevent XSS attacks + if (!isAllowedUri(HTMLAttributes.href)) { + // strip out the href + return [ + 'a', + mergeAttributes(this.options.HTMLAttributes, { + ...HTMLAttributes, + href: '', + }), + 0, + ] + } + return [ 'a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), @@ -174,25 +273,37 @@ export const Link = Mark.create({ addPasteRules() { return [ markPasteRule({ - find: (text) => - find(text) - .filter((link) => { - if (this.options.validate) { - return this.options.validate(link.value) - } - - return true - }) - .filter((link) => link.isLink) - .map((link) => ({ - text: link.value, - index: link.start, - data: link, - })), + find: (text) => { + const foundLinks: PasteRuleMatch[] = [] + + if (text) { + const { validate } = this.options + const links = find(text).filter( + (item) => item.isLink && validate(item.value), + ) + + if (links.length) { + links.forEach((link) => + foundLinks.push({ + // remove non-ASCII characters + text: link.value.replace(/[^ -~]/g, ''), + data: { + href: link.href.replace(/[^ -~]/g, ''), + }, + index: link.start, + }), + ) + } + } + + return foundLinks + }, type: this.type, - getAttributes: (match) => ({ - href: match.data?.href, - }), + getAttributes: (match) => { + return { + href: match.data?.href, + } + }, }), ] }, @@ -204,6 +315,7 @@ export const Link = Mark.create({ plugins.push( autolink({ type: this.type, + defaultProtocol: this.options.defaultProtocol, validate: this.options.validate, }), ) @@ -221,6 +333,7 @@ export const Link = Mark.create({ plugins.push( pasteHandler({ editor: this.editor, + defaultProtocol: this.options.defaultProtocol, type: this.type, }), ) diff --git a/src/transformers/normalize.test.ts b/src/transformers/normalize.test.ts index 523759d..25d68d3 100644 --- a/src/transformers/normalize.test.ts +++ b/src/transformers/normalize.test.ts @@ -280,7 +280,7 @@ describe('Normalization: Article', () => { test('article content from thrid-party API', () => { expectNormalizeArticleHTML( - '\n\n\n\n\n
\n\n\n\n

金曲樂團百合花看透現代成人愛情關係 打造新世代抓姦神曲〈掠猴之歌〉

\n\n\n\n

榮獲金曲獎最佳台語專輯的百合花樂團,近期宣布啟動海外演出計畫,即將前進新加坡、馬來西亞演出,然而許久未跟台灣樂迷互動的他們,也特別推出全新〈掠猴之歌〉MV,搞怪奇幻色彩,宛如前世時空記憶,卻擁有千年不變的相同錯綜複雜感情議題,糾結惱人情緒,即便經過多次輪迴依舊解不開,原來「出軌」這件事,無論哪個世紀都可能存在。百合花打趣笑說:「掠猴就是抓偷吃的意思,也是每個人感情裡最不想遇到的事情,我們用幽默手法與旋律,窺探愛情裡各種面貌。」百合花特別也為〈掠猴之歌〉打造一款線上互動遊戲「掠猴之遊戲Monkey-Catching Game」,邀請樂迷替苦主「金蕉小姐」搜集猴子老公出軌的證據!

\n\n\n\n

百合花從首張大碟囊括金音獎最佳搖滾專輯、最佳新人,第二張作品贏得去年金曲獎最佳台語專輯殊榮,奠定當今獨立樂壇地位,樂團不斷嘗試以傳統樂器混搭流行旋律,透過幽默與嘲諷歌詞拼貼,企圖打造千禧世代的「後台灣新歌謠」,也表現出略帶異色卻又充滿社會百態風情的生命之作。其中描繪當代成人複雜愛情觀的〈掠猴之歌〉,名符其實是一首「抓姦歌」,樂團比擬歌曲如同奧斯卡大導演「柯恩兄弟」的瀟灑態度,卻又加入金馬獎導演黃信堯擅於小人物縮影觀察,濃縮成一首現代社會面對愛情出軌的直球對決。

\n\n\n\n
\n\n\n\n
\n\n\n\n

MV上演抓姦千年穿越劇 警世寓言大讚宛如百合花式「戲說台灣」!

\n\n\n\n

也因為〈掠猴之歌〉散發一股獨特電影感氣息,百合花決定讓MV呈現樂團從未有過的影像風格,特別邀請曾入圍金曲最佳MV的導演林毛執導,攜手藝術家sid and geri合力操刀,把感情裡最不想觸碰的「抓猴」議題,也能變成一場又鏗又充滿警世意味寓言遊戲。
MV把場景拉到西元前3000年,在千年世紀之前,同樣也有出軌問題,女主角找來專業徵信雙人組做一場交易,藉由奇異魔法與幻術,企圖找出另一半偷吃證據,導演特別跟現代時空交錯,彷彿上演一場前世今生的「抓姦輪迴」大戲,他也表示:「過去華語歌曲常以哀怨節奏描繪外遇、偷吃等感情失敗關係,但百合花反其道而行,改以自嘲幽默手法,讓抓姦不再只是一場社會悲劇。」樂團成員看到MV成品後大讚導演瘋狂創意,繽紛視覺加上穿越劇方式,笑稱「我們也有屬於百合花式風格的《細說台灣》了!

\n\n\n\n
\n\n\n\n

推出線上互動「掠猴之遊戲」 邀請樂迷一起上網協尋偷吃出軌證據
百合花宣布啟動海外巡演計畫! 四月底前進星馬演出

\n\n\n\n
\n\n\n\n
\n\n\n\n

推出線上互動「掠猴之遊戲」 邀請樂迷一起上網協尋偷吃出軌證據

\n\n\n\n

〈掠猴之歌〉MV還埋藏樂團先前推出的線上互動遊戲「掠猴之遊戲Monkey-Catching Game」彩蛋,邀請樂迷一邊看MV也能從中挖掘,主唱奕碩表示:「〈掠猴之歌〉的編曲散發一股電視遊樂器復古感,所以我們打造一款8bit風格小遊戲,還讓三位團員化身三種樂器人物,包含月琴、電貝斯、北管通鼓,陪伴玩家一起闖關。」遊戲融合台灣既有街景文化特色,出現手搖杯店、小吃攤等,讓樂迷扮起偵探,協助苦主「金蕉小姐」搜集猴子老公出軌的證據。隨著MV與遊戲一併公開,百合花也宣布將啟動「2023 變猴弄」海外巡迴,將在4月底前進新加坡、馬來西亞,將台灣獨特音樂特色推廣到世界各地。

\n\n\n\n
百合花宣布啟動海外巡演計畫! 四月底前進星馬演出
\n\n\n\n

\n', + '\n\n\n\n\n
\n\n\n\n

金曲樂團百合花看透現代成人愛情關係 打造新世代抓姦神曲〈掠猴之歌〉

\n\n\n\n

榮獲金曲獎最佳台語專輯的百合花樂團,近期宣布啟動海外演出計畫,即將前進新加坡、馬來西亞演出,然而許久未跟台灣樂迷互動的他們,也特別推出全新〈掠猴之歌〉MV,搞怪奇幻色彩,宛如前世時空記憶,卻擁有千年不變的相同錯綜複雜感情議題,糾結惱人情緒,即便經過多次輪迴依舊解不開,原來「出軌」這件事,無論哪個世紀都可能存在。百合花打趣笑說:「掠猴就是抓偷吃的意思,也是每個人感情裡最不想遇到的事情,我們用幽默手法與旋律,窺探愛情裡各種面貌。」百合花特別也為〈掠猴之歌〉打造一款線上互動遊戲「掠猴之遊戲Monkey-Catching Game」,邀請樂迷替苦主「金蕉小姐」搜集猴子老公出軌的證據!

\n\n\n\n

百合花從首張大碟囊括金音獎最佳搖滾專輯、最佳新人,第二張作品贏得去年金曲獎最佳台語專輯殊榮,奠定當今獨立樂壇地位,樂團不斷嘗試以傳統樂器混搭流行旋律,透過幽默與嘲諷歌詞拼貼,企圖打造千禧世代的「後台灣新歌謠」,也表現出略帶異色卻又充滿社會百態風情的生命之作。其中描繪當代成人複雜愛情觀的〈掠猴之歌〉,名符其實是一首「抓姦歌」,樂團比擬歌曲如同奧斯卡大導演「柯恩兄弟」的瀟灑態度,卻又加入金馬獎導演黃信堯擅於小人物縮影觀察,濃縮成一首現代社會面對愛情出軌的直球對決。

\n\n\n\n
\n\n\n\n
\n\n\n\n

MV上演抓姦千年穿越劇 警世寓言大讚宛如百合花式「戲說台灣」!

\n\n\n\n

也因為〈掠猴之歌〉散發一股獨特電影感氣息,百合花決定讓MV呈現樂團從未有過的影像風格,特別邀請曾入圍金曲最佳MV的導演林毛執導,攜手藝術家sid and geri合力操刀,把感情裡最不想觸碰的「抓猴」議題,也能變成一場又鏗又充滿警世意味寓言遊戲。
MV把場景拉到西元前3000年,在千年世紀之前,同樣也有出軌問題,女主角找來專業徵信雙人組做一場交易,藉由奇異魔法與幻術,企圖找出另一半偷吃證據,導演特別跟現代時空交錯,彷彿上演一場前世今生的「抓姦輪迴」大戲,他也表示:「過去華語歌曲常以哀怨節奏描繪外遇、偷吃等感情失敗關係,但百合花反其道而行,改以自嘲幽默手法,讓抓姦不再只是一場社會悲劇。」樂團成員看到MV成品後大讚導演瘋狂創意,繽紛視覺加上穿越劇方式,笑稱「我們也有屬於百合花式風格的《細說台灣》了!

\n\n\n\n
\n\n\n\n

推出線上互動「掠猴之遊戲」 邀請樂迷一起上網協尋偷吃出軌證據
百合花宣布啟動海外巡演計畫! 四月底前進星馬演出

\n\n\n\n
\n\n\n\n
\n\n\n\n

推出線上互動「掠猴之遊戲」 邀請樂迷一起上網協尋偷吃出軌證據

\n\n\n\n

〈掠猴之歌〉MV還埋藏樂團先前推出的線上互動遊戲「掠猴之遊戲Monkey-Catching Game」彩蛋,邀請樂迷一邊看MV也能從中挖掘,主唱奕碩表示:「〈掠猴之歌〉的編曲散發一股電視遊樂器復古感,所以我們打造一款8bit風格小遊戲,還讓三位團員化身三種樂器人物,包含月琴、電貝斯、北管通鼓,陪伴玩家一起闖關。」遊戲融合台灣既有街景文化特色,出現手搖杯店、小吃攤等,讓樂迷扮起偵探,協助苦主「金蕉小姐」搜集猴子老公出軌的證據。隨著MV與遊戲一併公開,百合花也宣布將啟動「2023 變猴弄」海外巡迴,將在4月底前進新加坡、馬來西亞,將台灣獨特音樂特色推廣到世界各地。

\n\n\n\n
百合花宣布啟動海外巡演計畫! 四月底前進星馬演出
\n\n\n\n

\n', '

金曲樂團百合花打造新世代抓姦神曲〈掠猴之歌〉(照片:Taiwan Beats提供)


金曲樂團百合花看透現代成人愛情關係 打造新世代抓姦神曲〈掠猴之歌〉

榮獲金曲獎最佳台語專輯的百合花樂團,近期宣布啟動海外演出計畫,即將前進新加坡、馬來西亞演出,然而許久未跟台灣樂迷互動的他們,也特別推出全新〈掠猴之歌〉MV,搞怪奇幻色彩,宛如前世時空記憶,卻擁有千年不變的相同錯綜複雜感情議題,糾結惱人情緒,即便經過多次輪迴依舊解不開,原來「出軌」這件事,無論哪個世紀都可能存在。百合花打趣笑說:「掠猴就是抓偷吃的意思,也是每個人感情裡最不想遇到的事情,我們用幽默手法與旋律,窺探愛情裡各種面貌。」百合花特別也為〈掠猴之歌〉打造一款線上互動遊戲「掠猴之遊戲Monkey-Catching Game」,邀請樂迷替苦主「金蕉小姐」搜集猴子老公出軌的證據!

百合花從首張大碟囊括金音獎最佳搖滾專輯、最佳新人,第二張作品贏得去年金曲獎最佳台語專輯殊榮,奠定當今獨立樂壇地位,樂團不斷嘗試以傳統樂器混搭流行旋律,透過幽默與嘲諷歌詞拼貼,企圖打造千禧世代的「後台灣新歌謠」,也表現出略帶異色卻又充滿社會百態風情的生命之作。其中描繪當代成人複雜愛情觀的〈掠猴之歌〉,名符其實是一首「抓姦歌」,樂團比擬歌曲如同奧斯卡大導演「柯恩兄弟」的瀟灑態度,卻又加入金馬獎導演黃信堯擅於小人物縮影觀察,濃縮成一首現代社會面對愛情出軌的直球對決。


MV上演抓姦千年穿越劇 警世寓言大讚宛如百合花式「戲說台灣」!

也因為〈掠猴之歌〉散發一股獨特電影感氣息,百合花決定讓MV呈現樂團從未有過的影像風格,特別邀請曾入圍金曲最佳MV的導演林毛執導,攜手藝術家sid and geri合力操刀,把感情裡最不想觸碰的「抓猴」議題,也能變成一場又鏗又充滿警世意味寓言遊戲。
MV把場景拉到西元前3000年,在千年世紀之前,同樣也有出軌問題,女主角找來專業徵信雙人組做一場交易,藉由奇異魔法與幻術,企圖找出另一半偷吃證據,導演特別跟現代時空交錯,彷彿上演一場前世今生的「抓姦輪迴」大戲,他也表示:「過去華語歌曲常以哀怨節奏描繪外遇、偷吃等感情失敗關係,但百合花反其道而行,改以自嘲幽默手法,讓抓姦不再只是一場社會悲劇。」樂團成員看到MV成品後大讚導演瘋狂創意,繽紛視覺加上穿越劇方式,笑稱「我們也有屬於百合花式風格的《細說台灣》了!


推出線上互動「掠猴之遊戲」 邀請樂迷一起上網協尋偷吃出軌證據
百合花宣布啟動海外巡演計畫! 四月底前進星馬演出

推出線上互動「掠猴之遊戲」 邀請樂迷一起上網協尋偷吃出軌證據

〈掠猴之歌〉MV還埋藏樂團先前推出的線上互動遊戲「掠猴之遊戲Monkey-Catching Game」彩蛋,邀請樂迷一邊看MV也能從中挖掘,主唱奕碩表示:「〈掠猴之歌〉的編曲散發一股電視遊樂器復古感,所以我們打造一款8bit風格小遊戲,還讓三位團員化身三種樂器人物,包含月琴、電貝斯、北管通鼓,陪伴玩家一起闖關。」遊戲融合台灣既有街景文化特色,出現手搖杯店、小吃攤等,讓樂迷扮起偵探,協助苦主「金蕉小姐」搜集猴子老公出軌的證據。隨著MV與遊戲一併公開,百合花也宣布將啟動「2023 變猴弄」海外巡迴,將在4月底前進新加坡、馬來西亞,將台灣獨特音樂特色推廣到世界各地。

百合花宣布啟動海外巡演計畫! 四月底前進星馬演出

', ) })