-
Notifications
You must be signed in to change notification settings - Fork 72
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
base: main
Are you sure you want to change the base?
リンクカードを実装 #8139
Changes from all commits
afd8ca1
72edc3f
86e6d03
7f4c655
c75e552
2804231
7e3f9a0
228ef2a
016328e
6c528ff
3684f86
e3e0e5f
4d9161d
c5d774f
26a833f
d3d703a
3d0a821
31df358
3cb93b6
605f804
5b4f7fe
b7f558a
b96efcc
a96d722
1472836
5286664
6df9ff7
bbfa49d
c7df95b
d1f02ff
0d5d1ed
dc6ebb9
d6bb763
8b34f65
26ef499
3d1b4b0
d7b3a62
5c109e4
c2712ab
50259ac
3b4f83a
92902c3
483b013
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 | ||
} | ||
} |
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() | ||
} | ||
|
||
targetLink.classList.add('processed') | ||
}) | ||
} | ||
|
||
const isTweet = (url) => { | ||
return /^https:\/\/(twitter|x)\.com\/[a-zA-Z0-9_-]+\/status\/[a-zA-Z0-9?=&\-_]+$/.test( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Twitter(X)アカウントそのもの(例:https://x.com/fjordbootcamp )のページを貼り付けると、以下のように存在しないTweetと同じような表示形式になります。 ![]() 試しにはてなブログとQiitaにて、「アカウントページを貼り付けたらどのような挙動になるか」を試してみましたが、以下のようになりました。
![]()
![]() 基本はTwitter(X)の引用はほぼほぼツイートだと思われますし、貼り付けたURLも表示はされるので大きな問題ではないとは思います。 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
url | ||
) | ||
} | ||
|
||
const isTwitter = (url) => { | ||
return /^https:\/\/(twitter|x)\.com/.test(url) | ||
} | ||
|
||
const loadTwitterScript = () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. この関数でTweetのデザインを適用しています。(最終的なデザインはbootcampアプリ内で整えています) |
||
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`, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 初めは |
||
{ | ||
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() | ||
} |
There was a problem hiding this comment.
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要素を削除しています)