Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

リンクカードを実装 #8139

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
afd8ca1
Markdown-it リンクカードPluginの実装
mousu-a Oct 18, 2024
72edc3f
リンクカード関数の実装
mousu-a Oct 18, 2024
86e6d03
metadataを用意するAPIの中身を実装
mousu-a Oct 18, 2024
7f4c655
動作確認用のフィクスチャを追加
mousu-a Oct 18, 2024
c75e552
リンクカードにデザインを入れた
machida Oct 21, 2024
2804231
widgets.jsの不要な読み込みをなくした
machida Oct 21, 2024
7e3f9a0
twitterのときのデザインを追加
machida Oct 21, 2024
228ef2a
リンクカードの親要素のpタグを削除する処理を追加
machida Oct 21, 2024
016328e
エラーを出すようにし、エラー時の表示、取得出来ない項目があったときのデザイン、読み込み中が出っぱなしなのを修正
machida Oct 21, 2024
6c528ff
不要なコメントを削除
machida Oct 21, 2024
3684f86
inlineの場合はリンクカードが展開されないようにした
machida Nov 5, 2024
e3e0e5f
仕様に合わせるためプラグインのコードを変更
mousu-a Nov 12, 2024
4d9161d
仕様に合わせてフィクスチャの中身を変更
mousu-a Nov 12, 2024
c5d774f
リンクカードの前後に空のpタグが出力されてしまう問題を解決
mousu-a Nov 13, 2024
26a833f
リンクカードの生成後、元となったaタグ(の親要素であるpタグ)を削除する処理を追加
mousu-a Nov 13, 2024
d3d703a
リンクカードのデザイン微調整とclass名の整理
machida Nov 15, 2024
3d0a821
リンクカードが3つ以上存在するとき、最後のリンクカードが生成されない問題を解決
mousu-a Nov 16, 2024
31df358
フィクスチャに確認要項を追加
mousu-a Nov 16, 2024
3cb93b6
不要な記号を削除、記法の後ろに文字がある場合も弾くように改良した
mousu-a Nov 20, 2024
605f804
記法のリンクがURL(且つhttp / https)でない場合処理を行わないように
mousu-a Nov 20, 2024
5b4f7fe
messageとdetailsだけ許容する形に
mousu-a Nov 21, 2024
b7f558a
フィクスチャの確認要項の追加、カバレッジをあげた
mousu-a Nov 21, 2024
b96efcc
リンクカード システムテストを導入
mousu-a Dec 16, 2024
a96d722
ドメインのチェックをLinkCheckerクラスに移譲
mousu-a Dec 29, 2024
1472836
Url::Clientクラスのモデルテストを追加
mousu-a Dec 29, 2024
5286664
リンクカードのモデルテストを追加
mousu-a Dec 29, 2024
6df9ff7
フィクスチャを整理
mousu-a Dec 30, 2024
bbfa49d
messageを修正
mousu-a Jan 2, 2025
c7df95b
わかりやすいモジュール名に変更
mousu-a Jan 2, 2025
d1f02ff
勘違いさせないように
mousu-a Jan 7, 2025
0d5d1ed
実態に即した変数名に
mousu-a Jan 12, 2025
dc6ebb9
Prettierの指摘事項を修正
mousu-a Jan 15, 2025
d6bb763
Twitter関連のリンクカードはツイートにのみ対応する。
mousu-a Feb 13, 2025
8b34f65
フィクスチャを仕様に対応させるため変更
mousu-a Feb 13, 2025
26ef499
リンクがXの場合、リンクカードにXアイコンを出すようにした
machida Feb 17, 2025
3d1b4b0
よりわかりやすい変数名に
mousu-a Feb 18, 2025
d7b3a62
getAttributeより範囲が狭いdatasetに変更
mousu-a Feb 18, 2025
5c109e4
リンクのURLを引数で渡しDRYに
mousu-a Feb 18, 2025
c2712ab
sendより範囲の狭いpublic_sendに変更
mousu-a Feb 18, 2025
50259ac
クラス名と変数名で意味が重複していたので解消
mousu-a Feb 20, 2025
3b4f83a
よりわかりやすく
mousu-a Feb 20, 2025
92902c3
既存の概念を使用してよりわかりやすく
mousu-a Feb 20, 2025
483b013
外から使われないメソッドをprivateに
mousu-a Feb 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/controllers/api/metadata_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class API::MetadataController < ApplicationController
def index
card = LinkCard::Card.new(params)
render json: card.metadata
end
end
4 changes: 4 additions & 0 deletions app/javascript/markdown-initializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import MarkDownItContainerMessage from 'markdown-it-container-message'
import MarkDownItContainerDetails from 'markdown-it-container-details'
import MarkDownItLinkAttributes from 'markdown-it-link-attributes'
import MarkDownItContainerSpeak from 'markdown-it-container-speak'
import MarkDownItLinkToCard from 'markdown-it-link-to-card'
import ReplaceLinkToCard from 'replace-link-to-card'

export default class {
replace(selector) {
Expand All @@ -27,6 +29,7 @@ export default class {

new UserIconRenderer().render(selector)
MarkdownItTaskListsInitializer.initialize()
ReplaceLinkToCard(elements)
}

render(text) {
Expand All @@ -53,6 +56,7 @@ export default class {
}
})
md.use(MarkDownItContainerSpeak)
md.use(MarkDownItLinkToCard)
return md.render(text)
}
}
74 changes: 74 additions & 0 deletions app/javascript/markdown-it-link-to-card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export default (md, _options) => {
md.core.ruler.after('replacements', 'link-to-card', function (state) {
const tokens = state.tokens
let allowLevel = 0
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (
token.type === 'container_details_open' ||
token.type === 'container_message_open'
) {
allowLevel++
continue
}
if (
(token.type === 'container_details_close' && allowLevel > 0) ||
(token.type === 'container_message_close' && allowLevel > 0)
) {
allowLevel--
continue
}
// リンクの親のp要素が他のマークダウンにネストしていない場合のみリンクカードを生成する(details, message以外)
const parentToken = tokens[i - 1]
const isAllowedParagraph =
parentToken &&
parentToken.type === 'paragraph_open' &&
parentToken.level === allowLevel
if (!isAllowedParagraph) continue

// 記法の対象となるlinkはinlineTokenのchildrenにのみ存在する
if (token.type !== 'inline') continue

const children = token.children
if (!children) continue

// childrenにlinkが存在する場合のみリンクカードを生成する
const hasLink = children?.some((child) => child.type === 'link_open')
if (!hasLink) continue

// childrenにbrが存在しない場合のみリンクカードを生成する
const hasBr = children?.some((child) => child.type === 'softbreak')
if (hasBr) continue

const match = token.content.match(/^@\[card\]\((.+)\)$/)
if (!match) continue

const linkCardUrl = match[1]
if (!isValidHttpUrl(linkCardUrl)) continue

const linkCardToken = new state.Token('html_block', '', 0)
linkCardToken.content = `
<div class="a-link-card before-replacement-link-card" data-url="${md.utils.escapeHtml(
linkCardUrl
)}">
<p>リンクカード適用中... <i class="fa-regular fa-loader fa-spin"></i></p>
</div>
`
// tokens[i]の位置にpushしてしまうと、Tokenの配列として正しい形でなくなり、想定していないHTMLが出力されてしまう。
// 正しいTokenの配列を保つために、linkCardの一連のtoken(pOpen inline pClose)の後にpushしている
tokens.splice(i + 2, 0, linkCardToken)

parentToken.attrJoin('style', 'display: none')
}
return true
})
}

const isValidHttpUrl = (str) => {
try {
const url = new URL(str)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch (_) {
return false
}
}
193 changes: 193 additions & 0 deletions app/javascript/replace-link-to-card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import CSRF from 'csrf'
import { debounce } from './debounce.js'

export default (textareas) => {
replaceLinkToCard()
const debouncedReplace = debounce(replaceLinkToCard, 300)

Array.from(textareas).forEach((textarea) => {
textarea.addEventListener('input', debouncedReplace)
})
}

const replaceLinkToCard = () => {
const targetLinkList = document.querySelectorAll(
'.before-replacement-link-card:not(.processed)'
)

targetLinkList.forEach((targetLink) => {
const url = targetLink.dataset.url
if (!url) {
console.error('URLが取得できませんでした:', targetLink)
handleEmbedFailure(targetLink, url)
return
}

if (isTweet(url)) {
embedToTweet(targetLink, url).catch(() =>
handleEmbedFailure(targetLink, url)
)
} else {
embedToLinkCard(targetLink, url).catch(() =>
handleEmbedFailure(targetLink, url)
)
}

const sibling = targetLink.previousElementSibling
if (sibling.tagName === 'P') {
sibling.insertAdjacentElement('afterend', targetLink)
sibling.remove()
Copy link
Contributor Author

@mousu-a mousu-a Jan 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

リンクカードの元となったリンクを削除しています。
(Markdown-itはリンクが<p><a></a></p>の形で出力されるため、リンクの親要素であるP要素を削除しています)

}

targetLink.classList.add('processed')
})
}

const isTweet = (url) => {
return /^https:\/\/(twitter|x)\.com\/[a-zA-Z0-9_-]+\/status\/[a-zA-Z0-9?=&\-_]+$/.test(
Copy link
Contributor

@ayu-0505 ayu-0505 Feb 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Twitter(X)アカウントそのもの(例:https://x.com/fjordbootcamp )のページを貼り付けると、以下のように存在しないTweetと同じような表示形式になります。

スクリーンショット 2025-02-07 11 02 50

試しにはてなブログとQiitaにて、「アカウントページを貼り付けたらどのような挙動になるか」を試してみましたが、以下のようになりました。

  • はてなブログでアカウントを埋め込み形式で表示
    ポストと同じ形式を使っているためか、少し違和感のあるデザイン。
スクリーンショット 2025-02-07 11 06 14
  • Qiitaでアカウントを埋め込み形式で表示(比較としてポストも)
    アカウントを貼り付けた時とポストを貼り付けた時でリンクカードデザインが異なる。
スクリーンショット 2025-02-07 11 07 50

基本はTwitter(X)の引用はほぼほぼツイートだと思われますし、貼り付けたURLも表示はされるので大きな問題ではないとは思います。
また、対応する難度がどれくらいかがちょっと予想つかないです💦
こちらについて、対応されるかどうかのご検討をいただけたらと思います🙏

Copy link
Contributor Author

@mousu-a mousu-a Feb 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

こちらコメントでのやりとりにありますように対応していただきました!
ありがとうございました🙇‍♂️
d6bb763
26ef499
8b34f65

url
)
}

const isTwitter = (url) => {
return /^https:\/\/(twitter|x)\.com/.test(url)
}

const loadTwitterScript = () => {
Copy link
Contributor Author

@mousu-a mousu-a Jan 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この関数でTweetのデザインを適用しています。(最終的なデザインはbootcampアプリ内で整えています)

JSでツイートを埋め込むときのベストプラクティス

if (
!document.querySelector(
'script[src="https://platform.twitter.com/widgets.js"]'
)
) {
const twitterScript = document.createElement('script')
twitterScript.src = 'https://platform.twitter.com/widgets.js'
document.body.appendChild(twitterScript)
}
}

const embedToTweet = async (targetLink, url) => {
try {
const response = await fetch(
`/api/metadata?url=${encodeURIComponent(url)}&tweet=1`,
Copy link
Contributor Author

@mousu-a mousu-a Jan 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

初めはtweet=trueで送っていたのですが、paramsで送ると絶対に文字列になってしまうので、コントローラで受け取るときにStringの"true"となってしまいます。
動きとしてはどちらでも問題はありませんが、勘違いを避けるために"フラグが立っている状態"という意味で"1"を送っています。

{
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': CSRF.getToken()
},
credentials: 'same-origin',
redirect: 'manual'
}
)

if (!response.ok) throw new Error(`Error: ${response.status}`)

const embedTweet = await response.json()
targetLink.insertAdjacentHTML('afterend', embedTweet.html)
targetLink.remove()
loadTwitterScript()
} catch (error) {
console.error('Tweetの埋め込みに失敗しました:', error)
handleEmbedFailure(targetLink, url)
}
}

const embedToLinkCard = async (targetLink, url) => {
try {
const response = await fetch(
`/api/metadata?url=${encodeURIComponent(url)}`,
{
headers: {
'Content-Type': 'application/json; charset=utf-8',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': CSRF.getToken()
},
credentials: 'same-origin',
redirect: 'manual'
}
)

if (!response.ok) throw new Error(`Error: ${response.status}`)

const metaData = await response.json()

const imageSection = metaData.image
? `
<div class="a-link-card__image"><a href="${
metaData.site_url || ''
}" target="_blank" rel="noopener" class="a-link-card__image-link">
<img src="${metaData.image}" alt="${
metaData.title || 'Site Image'
}" class="a-link-card__image-ogp" />
</a></div>
`
: ''

const descriptionSection = metaData.description
? `<p>${metaData.description}</p>`
: `<p><a href="${url}" target="_blank" rel="noopener" class="a-link-card__body-url-link">${url}</a></p>`

const faviconSection = metaData.favicon
? `
<img src="${metaData.favicon}" alt="Favicon" class="a-link-card__favicon-image" />
`
: ''

targetLink.insertAdjacentHTML(
'afterend',
`
<div class="a-link-card">
<div class="a-link-card__title">
<a href="${url}" target="_blank" rel="noopener" class="a-link-card__title-link">
<div class="a-link-card__title-text">${
metaData.title || 'No Title'
}</div>
</a>
</div>
<div class="a-link-card__body">
<div class="a-link-card__body-start">
<div class="a-link-card__description">
${descriptionSection}
</div>
<div class="a-link-card__site-title">
<a href="${
metaData.site_url || ''
}" target="_blank" rel="noopener" class="a-link-card__site-title-link">
${faviconSection}
<div class="a-link-card__site-title-text">${
metaData.site_name || 'No Site Name'
}</div>
</a>
</div>
</div>
<div class="a-link-card__body-end">
${imageSection}
</div>
</div>
</div>
`
)
targetLink.remove()
} catch (error) {
console.error('リンクカードの埋め込みに失敗しました:', error)
handleEmbedFailure(targetLink, url)
}
}

const handleEmbedFailure = (targetLink, url) => {
targetLink.insertAdjacentHTML(
'afterend',
`
<div class="a-link-card embed-error">
<!-- リンクの変換に失敗しました。以下のリンクをご確認ください。 -->
<div class="embed-error__inner">
${isTwitter ? '<i class="fa-brands fa-x-twitter"></i>' : ''}
<a href="${url}" target="_blank" rel="noopener">
${url}
</a>
</div>
</div>
`
)
targetLink.remove()
}
1 change: 1 addition & 0 deletions app/javascript/stylesheets/_common-imports.sass
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
@import "atoms/a-side-nav"
@import "atoms/a-notice-block"
@import "atoms/a-table"
@import "atoms/a-link-card"

////////////
// layouts
Expand Down
Loading