Appearance
x-linkというタグを定義した
2022-01-29
ふとした勢いで init し、今はひっそりと作っている自分用の web components ライブラリに、attribute として与えたリンクの title、description、favicon を取得してそれっぽくレンダリングする仕組みを作った。(docs)
これは後々 rintonmd の a タグレンダリングに使用する予定。(卒論提出したら着手する)
下のような形で使用でき、docs にある playground で見てみると、こんな感じになっている。
<x-link link="https://dev.takurinton.com/"></x-link>
1
実装の手法
実装方法は特別なことはしておらず、基本的な web components の定義に則って class を定義して実装している。
class Component extends HTMLElement {
constructor() {
super();
const shadow = document.createElement("span"); // ここで shadow root を作成
shadow.innerHTML = ""; // shadow root の下に展開していくマークアップを記述する
}
}
customElement.define("x-component", Component);
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
また、今回はリンク先の情報を取得するためにサーバサイドに一度リクエストを投げ、対象の html を text として返してもらい、レンダリングを行なっている。
クライアントサイドからリクエストを投げると CORS の制約に引っかかる。そのため、一度サーバを噛ませている。
サーバは Go で書いており、gin で作ったリクエスト群の中に og
というエンドポイントを定義し、url
というクエリパラメータを見てサイトの情報を取得する。
形式はこんな感じ → https://endpoint/og?url=https://example.com
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/og", func (c *gin.Context) {
url := c.DefaultQuery("url", "")
resp, _ := http.Get(url)
defer resp.Body.Close()
byteArray, _ := ioutil.ReadAll(resp.Body) // byte[] で取得、c.Data の第三引数が byte[] のため
c.Data(http.StatusOK, "text/html; charset=utf-8", byteArray) // 取得した html を返している
})
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
実装
具体的な実装について見ていく。
上で書いたように、class を定義して実装していく。
class 内に private な関数を 2 つ定義した。
getData
先ほど Go で定義したエンドポイントに対してリクエストを投げ、html を取得するための関数。
export class Link extends HTMLElement {
constructor() {
super();
// 処理
}
private async getData(link: string) {
if (typeof window !== "undefined") {
return await fetch(`https://api.takurinton.com/og?url=${link}`)
.then((res) => {
if (res.ok) return res.text();
else console.log(res);
})
.then((text) => {
if (text !== undefined)
return new DOMParser().parseFromString(text, "text/html");
});
}
return undefined;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fetch API を利用していて、SSR すると runtime でエラーを吐くため、CSR ではない時は undefined を返すようにしている。
getMetaTags
次に meta tag の中身を取得し、string の値として返す部分。
上の getData 関数で取得した html の head タグの中身を取得し、return している。
favicon は定義の形式が複数あるため、null 合体演算子でどちらかは取得できるようにしている。
export class Link extends HTMLElement {
constructor() {
super();
// 処理
}
private getMetaTags(html: Document, link: string) {
const description = html.getElementsByName("description")[0];
const favicon =
html.querySelector('link[rel="icon"]') ??
html.querySelector('link[rel="shortcut icon"]');
const domain = link.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/)[1];
let image;
if (favicon === undefined) {
image = "";
} else if (
favicon.href.slice(0, 5) === "https" &&
favicon.href.slice(0, 16) !== "https://rintonwc"
) {
// when favicon.href with origin + path
const file = favicon.href;
const fileLink = file.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/);
if (fileLink === null) image = `https://${domain}${file.slice(7)}`;
else if (fileLink[1] !== domain) {
const filePathSplit = file.split("/")[3];
image = `https://${fileLink[1]}/${filePathSplit}`;
}
} else {
// when favicon.href with only absolute path
const file = favicon.href;
const fileLink = file.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/);
if (fileLink === null) image = `https://${domain}${file.slice(7)}`;
else {
const filePathSplit = file.split("/").slice(3).join("/");
image = `https://${domain}/${filePathSplit}`;
}
}
return {
title: html.title,
description: description === undefined ? "" : description.content,
image,
};
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
少しスコープごとにみる。今回、title、description は、あれば文字列、なければ空文字でいいが、favicon はそうもいかない。
考えなければいけないことは以下の 3 パターンある。
- favicon が undefined
- favicon が origin まで含めた値(例:
<link rel="shortcut icon" href="https://example.com/favicon.ico" type="image/x-icon">
) - favicon が絶対パスの場合(例:
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
)
上記を踏まえた上で、条件分岐を書くとすると、以下のようになる。
// favicon が存在しない時
if (favicon === undefined) {
image = "";
// favicon の先頭が https から始まる(つまり origin まで含む、かつ自分のドキュメントではない時)
} else if {
favicon.href.slice(0, 5) === "https" &&
favicon.href.slice(0, 16) !== "https://rintonwc"
) {
// when favicon.href with origin + path
const file = favicon.href;
const fileLink = file.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/);
if (fileLink === null) image = `https://${domain}${file.slice(7)}`;
else if (fileLink[1] !== domain) {
const filePathSplit = file.split("/")[3];
image = `https://${fileLink[1]}/${filePathSplit}`;
}
// favicon がパスのみの時
} else {
// when favicon.href with only absolute path
const file = favicon.href;
const fileLink = file.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/);
if (fileLink === null) image = `https://${domain}${file.slice(7)}`;
else {
const filePathSplit = file.split("/").slice(3).join("/");
image = `https://${domain}/${filePathSplit}`;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
少し雑な書き方だが、このような形で分岐をしている。
マークアップの部分
マークアップの部分は以下のようになっている。
先ほど定義した getData
、getMetaTags
を使用して必要なデータを取得し、レンダリングしている。
style に関してはそこまで特別なことはしていないので割愛。
export class Link extends HTMLElement {
constructor() {
super();
const shadow = document.createElement("span");
const link = this.getAttribute("link") as string;
(async () => {
const html = (await this.getData(link)) as Document;
if (html === undefined) return;
const { title, description, image } = this.getMetaTags(html, link);
shadow.innerHTML = `
<style>
a {
border: 1px gray solid;
border-radius: 5px;
width: 80%;
padding: 10px;
display: flex;
text-decoration: none;
color: #222222;
}
.left {
height: 100px;
width: 100px;
text-align: center;
padding-right: 40px;
}
.left > img {
height: 100px;
width: 100px;
}
.right {
display: block;
overflow: hidden;
}
.right > h1,
.right > p,
.right > a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-overflow: ellipsis;
}
.right > h1 {
height: 50px;
margin: 0;
}
.right > p {
margin: 0;
}
.link {
color: gray;
}
</style>
<a href="${link}" target="_blank">
<div class="left">
<img src="${image}" alt="${title}" />
</div>
<div class="right">
<h1>${title}</h1>
<p class="description">${description}</p>
<p class="link">${link}</p>
</div>
</a>
`;
this.attachShadow({ mode: "open" }).appendChild(shadow); // shadow root に attach
})();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
締め
サーバを用意するのがめんどくさかったけど、そこまで難しくなかった。
rintonmd の parser の部分が完成したら、renderer のところに組み込んで運用していきたい。