@mannisto/astro-metadata 1.0.2 → 2.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +427 -82
- package/package.json +25 -14
- package/src/assets/icons/LICENSE.md +35 -0
- package/src/assets/icons/binoculars.svg +1 -0
- package/src/assets/icons/caret-right.svg +1 -0
- package/src/assets/icons/check.svg +1 -0
- package/src/assets/icons/copy.svg +1 -0
- package/src/assets/icons/seal-check-duotone.svg +1 -0
- package/src/assets/icons/seal-question-duotone.svg +1 -0
- package/src/assets/icons/warning-circle-duotone.svg +1 -0
- package/src/assets/icons/warning-duotone.svg +1 -0
- package/src/assets/icons/x.svg +1 -0
- package/src/components/Metadata.astro +145 -0
- package/src/components/index.ts +2 -0
- package/src/integration.d.ts +112 -0
- package/src/integration.js +508 -0
- package/src/lib/checks.d.ts +70 -0
- package/src/lib/checks.js +193 -0
- package/src/lib/config.ts +111 -0
- package/src/lib/favicon-files.d.ts +29 -0
- package/src/lib/favicon-files.js +103 -0
- package/src/lib/favicon-image.ts +124 -0
- package/src/lib/favicon.ts +167 -0
- package/src/lib/head.d.ts +25 -0
- package/src/lib/head.js +141 -0
- package/src/lib/manifest.ts +126 -0
- package/src/lib/rules.d.ts +73 -0
- package/src/lib/rules.js +902 -0
- package/src/lib/schema.ts +208 -0
- package/src/lib/site.ts +72 -0
- package/src/lib/url.ts +79 -0
- package/src/routes/favicon.ts +14 -0
- package/src/routes/manifest.ts +18 -0
- package/src/runtime.ts +16 -0
- package/src/toolbar/app.ts +72 -0
- package/src/toolbar/assets.ts +81 -0
- package/src/toolbar/data.ts +282 -0
- package/src/toolbar/define.ts +7 -0
- package/src/toolbar/env.d.ts +10 -0
- package/src/toolbar/highlight.ts +83 -0
- package/src/toolbar/styles/shared.css +18 -0
- package/src/toolbar/styles/tokens.css +182 -0
- package/src/toolbar/toolbar-badge.ts +78 -0
- package/src/toolbar/ui/alert/alert.css +126 -0
- package/src/toolbar/ui/alert/alert.ts +102 -0
- package/src/toolbar/ui/badge/badge.css +41 -0
- package/src/toolbar/ui/badge/badge.ts +24 -0
- package/src/toolbar/ui/code-block/code-block.css +90 -0
- package/src/toolbar/ui/code-block/code-block.ts +68 -0
- package/src/toolbar/ui/color-swatch/color-swatch.css +15 -0
- package/src/toolbar/ui/color-swatch/color-swatch.ts +31 -0
- package/src/toolbar/ui/icon/icon.css +11 -0
- package/src/toolbar/ui/icon/icon.ts +46 -0
- package/src/toolbar/ui/image/image.css +18 -0
- package/src/toolbar/ui/image/image.ts +58 -0
- package/src/toolbar/ui/inline-code/inline-code.css +16 -0
- package/src/toolbar/ui/inline-code/inline-code.ts +41 -0
- package/src/toolbar/ui/section/section.css +88 -0
- package/src/toolbar/ui/section/section.ts +78 -0
- package/src/toolbar/ui/tab/tab.css +7 -0
- package/src/toolbar/ui/tab/tab.ts +47 -0
- package/src/toolbar/ui/tab-group/tab-group.css +42 -0
- package/src/toolbar/ui/tab-group/tab-group.ts +101 -0
- package/src/toolbar/ui/table/table.css +124 -0
- package/src/toolbar/ui/table/table.ts +155 -0
- package/src/toolbar/views/code-blocks.ts +35 -0
- package/src/toolbar/views/metadata-panel.css +101 -0
- package/src/toolbar/views/metadata-panel.ts +125 -0
- package/src/toolbar/views/overview-view.css +27 -0
- package/src/toolbar/views/overview-view.ts +401 -0
- package/src/toolbar/views/previews/discord.css +70 -0
- package/src/toolbar/views/previews/discord.ts +60 -0
- package/src/toolbar/views/previews/facebook.css +65 -0
- package/src/toolbar/views/previews/facebook.ts +36 -0
- package/src/toolbar/views/previews/google.css +75 -0
- package/src/toolbar/views/previews/google.ts +36 -0
- package/src/toolbar/views/previews/linkedin.css +58 -0
- package/src/toolbar/views/previews/linkedin.ts +34 -0
- package/src/toolbar/views/previews/page.ts +129 -0
- package/src/toolbar/views/previews/platform.ts +14 -0
- package/src/toolbar/views/previews/previews-view.css +37 -0
- package/src/toolbar/views/previews/previews-view.ts +52 -0
- package/src/toolbar/views/previews/slack.css +61 -0
- package/src/toolbar/views/previews/slack.ts +47 -0
- package/src/toolbar/views/previews/twitter.css +89 -0
- package/src/toolbar/views/previews/twitter.ts +57 -0
- package/src/toolbar/views/previews/whatsapp.css +88 -0
- package/src/toolbar/views/previews/whatsapp.ts +65 -0
- package/src/toolbar/views/raw-view.ts +41 -0
- package/index.ts +0 -31
- package/src/components/Canonical.astro +0 -11
- package/src/components/Description.astro +0 -9
- package/src/components/Favicon.astro +0 -96
- package/src/components/Head.astro +0 -94
- package/src/components/Keywords.astro +0 -9
- package/src/components/LanguageAlternates.astro +0 -18
- package/src/components/OpenGraph.astro +0 -75
- package/src/components/Robots.astro +0 -25
- package/src/components/Schema.astro +0 -18
- package/src/components/Title.astro +0 -13
- package/src/components/Twitter.astro +0 -27
- package/src/lib/metadata.ts +0 -77
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Discord: an embed in a message, 432 px wide with a large image, else 520 px */
|
|
2
|
+
.discord {
|
|
3
|
+
width: 100%;
|
|
4
|
+
max-width: 552px;
|
|
5
|
+
padding: 16px;
|
|
6
|
+
font-family: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
7
|
+
border-radius: 8px;
|
|
8
|
+
background: #313338;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.discord .embed {
|
|
12
|
+
display: flex;
|
|
13
|
+
gap: 16px;
|
|
14
|
+
width: fit-content;
|
|
15
|
+
max-width: 520px;
|
|
16
|
+
padding: 8px 16px 16px 12px;
|
|
17
|
+
border-left: 4px solid;
|
|
18
|
+
border-radius: 4px;
|
|
19
|
+
background: #2b2d31;
|
|
20
|
+
|
|
21
|
+
&.large {
|
|
22
|
+
max-width: 432px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.body {
|
|
26
|
+
display: grid;
|
|
27
|
+
gap: 8px;
|
|
28
|
+
min-width: 0;
|
|
29
|
+
padding-top: 8px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.site {
|
|
33
|
+
font-size: 12px;
|
|
34
|
+
line-height: 16px;
|
|
35
|
+
color: #dbdee1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.title {
|
|
39
|
+
font-size: 16px;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
line-height: 22px;
|
|
42
|
+
color: #00a8fc;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.text {
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
line-height: 18px;
|
|
48
|
+
color: #dbdee1;
|
|
49
|
+
white-space: pre-line;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* A large image: under the text, at most 400 × 300 px */
|
|
53
|
+
.image {
|
|
54
|
+
width: 100%;
|
|
55
|
+
max-width: 400px;
|
|
56
|
+
max-height: 300px;
|
|
57
|
+
aspect-ratio: 1.91 / 1;
|
|
58
|
+
margin-top: 8px;
|
|
59
|
+
border-radius: 4px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* A small image: on the right */
|
|
63
|
+
.thumbnail {
|
|
64
|
+
flex: none;
|
|
65
|
+
width: 80px;
|
|
66
|
+
height: 80px;
|
|
67
|
+
margin-top: 8px;
|
|
68
|
+
border-radius: 3px;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { html, nothing, unsafeCSS } from "lit"
|
|
2
|
+
import { styleMap } from "lit/directives/style-map.js"
|
|
3
|
+
|
|
4
|
+
import "../../ui/image/image.ts"
|
|
5
|
+
|
|
6
|
+
import styles from "./discord.css?inline"
|
|
7
|
+
import { truncate } from "./page.ts"
|
|
8
|
+
import type { Platform } from "./platform.ts"
|
|
9
|
+
|
|
10
|
+
// The lengths that Discord shows
|
|
11
|
+
const TITLE_LENGTH = 256
|
|
12
|
+
const DESCRIPTION_LENGTH = 350
|
|
13
|
+
|
|
14
|
+
// The border color without a theme-color
|
|
15
|
+
const DEFAULT_BORDER = "#1e1f22"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A link embed in a message, in dark mode.
|
|
19
|
+
*
|
|
20
|
+
* - The color of the left border is the `theme-color`.
|
|
21
|
+
* - Uses `og:site_name`, `og:title`, `og:description` and `og:image`.
|
|
22
|
+
* - `twitter:card` with `summary_large_image` shows a large image under the text. Else the image is small, on the right.
|
|
23
|
+
* - Discord cuts the title at 256 characters and the description at 350.
|
|
24
|
+
*/
|
|
25
|
+
export const discord: Platform = {
|
|
26
|
+
name: "Discord",
|
|
27
|
+
styles: unsafeCSS(styles),
|
|
28
|
+
render(page) {
|
|
29
|
+
const large = page.twitter.card === "summary_large_image"
|
|
30
|
+
const image = page.og.image
|
|
31
|
+
const border = page.themeColor ?? DEFAULT_BORDER
|
|
32
|
+
// The description keeps its line breaks, so it must have no spaces around it
|
|
33
|
+
const text = truncate(page.og.description ?? page.description, DESCRIPTION_LENGTH)
|
|
34
|
+
|
|
35
|
+
return html`
|
|
36
|
+
<div class="discord">
|
|
37
|
+
<div
|
|
38
|
+
class="embed ${large ? "large" : "small"}"
|
|
39
|
+
style=${styleMap({ borderLeftColor: border })}
|
|
40
|
+
>
|
|
41
|
+
<div class="body">
|
|
42
|
+
${page.og.siteName ? html`<div class="site">${page.og.siteName}</div>` : nothing}
|
|
43
|
+
<div class="title">${truncate(page.og.title ?? page.title, TITLE_LENGTH)}</div>
|
|
44
|
+
<div class="text">${text}</div>
|
|
45
|
+
${
|
|
46
|
+
large && image
|
|
47
|
+
? html`<astro-metadata-image class="image" .src=${image}></astro-metadata-image>`
|
|
48
|
+
: nothing
|
|
49
|
+
}
|
|
50
|
+
</div>
|
|
51
|
+
${
|
|
52
|
+
!large && image
|
|
53
|
+
? html`<astro-metadata-image class="thumbnail" .src=${image}></astro-metadata-image>`
|
|
54
|
+
: nothing
|
|
55
|
+
}
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
`
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/* Facebook: a link preview in a post, 500 px wide */
|
|
2
|
+
.facebook {
|
|
3
|
+
width: 100%;
|
|
4
|
+
max-width: 500px;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
7
|
+
border-radius: 8px;
|
|
8
|
+
background: #242526;
|
|
9
|
+
|
|
10
|
+
&.large .image {
|
|
11
|
+
width: 100%;
|
|
12
|
+
aspect-ratio: 1.91 / 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* A small image: a square on the left of the text */
|
|
16
|
+
&.small {
|
|
17
|
+
display: flex;
|
|
18
|
+
|
|
19
|
+
.image {
|
|
20
|
+
flex: none;
|
|
21
|
+
width: 140px;
|
|
22
|
+
height: 140px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.body {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
min-width: 0;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.body {
|
|
34
|
+
padding: 10px 12px;
|
|
35
|
+
background: #3a3b3c;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.host {
|
|
39
|
+
font-size: 13px;
|
|
40
|
+
line-height: 16px;
|
|
41
|
+
color: #b0b3b8;
|
|
42
|
+
text-transform: uppercase;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.title {
|
|
46
|
+
display: -webkit-box;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
margin: 3px 0 2px;
|
|
49
|
+
font-size: 17px;
|
|
50
|
+
font-weight: 600;
|
|
51
|
+
line-height: 20px;
|
|
52
|
+
color: #e4e6eb;
|
|
53
|
+
-webkit-box-orient: vertical;
|
|
54
|
+
-webkit-line-clamp: 2;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.text {
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
font-size: 15px;
|
|
60
|
+
line-height: 20px;
|
|
61
|
+
color: #b0b3b8;
|
|
62
|
+
text-overflow: ellipsis;
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { html, unsafeCSS } from "lit"
|
|
2
|
+
|
|
3
|
+
import "../../ui/image/image.ts"
|
|
4
|
+
|
|
5
|
+
import styles from "./facebook.css?inline"
|
|
6
|
+
import type { Platform } from "./platform.ts"
|
|
7
|
+
|
|
8
|
+
// An image of this width or larger shows large. A smaller image shows as a small square.
|
|
9
|
+
const LARGE_WIDTH = 600
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A link preview in a post in dark mode.
|
|
13
|
+
*
|
|
14
|
+
* - Uses `og:title`, `og:description` and `og:image`, or else the `<title>` and the description.
|
|
15
|
+
* - An image of 600 × 315 px or larger shows large at 1.91:1. A smaller image shows as a small square.
|
|
16
|
+
* - An image smaller than 200 × 200 px does not show.
|
|
17
|
+
* - The title shows on two lines at most, and the description on one line.
|
|
18
|
+
*/
|
|
19
|
+
export const facebook: Platform = {
|
|
20
|
+
name: "Facebook",
|
|
21
|
+
styles: unsafeCSS(styles),
|
|
22
|
+
render(page) {
|
|
23
|
+
const width = page.og.imageWidth
|
|
24
|
+
const small = width !== undefined && width < LARGE_WIDTH
|
|
25
|
+
return html`
|
|
26
|
+
<div class="facebook ${small ? "small" : "large"}">
|
|
27
|
+
<astro-metadata-image class="image" .src=${page.og.image}></astro-metadata-image>
|
|
28
|
+
<div class="body">
|
|
29
|
+
<div class="host">${page.url.host}</div>
|
|
30
|
+
<div class="title">${page.og.title ?? page.title}</div>
|
|
31
|
+
<div class="text">${page.og.description ?? page.description}</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
`
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/* Google: a desktop search result, 600 px wide */
|
|
2
|
+
.google {
|
|
3
|
+
width: 100%;
|
|
4
|
+
max-width: 600px;
|
|
5
|
+
padding: 16px;
|
|
6
|
+
font-family: Arial, sans-serif;
|
|
7
|
+
border-radius: 8px;
|
|
8
|
+
background: #1f1f1f;
|
|
9
|
+
|
|
10
|
+
.site {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 12px;
|
|
14
|
+
margin-bottom: 4px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.favicon {
|
|
18
|
+
display: grid;
|
|
19
|
+
flex: none;
|
|
20
|
+
place-items: center;
|
|
21
|
+
width: 28px;
|
|
22
|
+
height: 28px;
|
|
23
|
+
border: 1px solid #3c4043;
|
|
24
|
+
border-radius: 50%;
|
|
25
|
+
background: #303134;
|
|
26
|
+
|
|
27
|
+
img {
|
|
28
|
+
width: 18px;
|
|
29
|
+
height: 18px;
|
|
30
|
+
object-fit: contain;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.source {
|
|
35
|
+
min-width: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.name {
|
|
39
|
+
font-size: 14px;
|
|
40
|
+
line-height: 20px;
|
|
41
|
+
color: #dadce0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.url {
|
|
45
|
+
overflow: hidden;
|
|
46
|
+
font-size: 12px;
|
|
47
|
+
line-height: 18px;
|
|
48
|
+
color: #bdc1c6;
|
|
49
|
+
text-overflow: ellipsis;
|
|
50
|
+
white-space: nowrap;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* One line: the title is already cut at 600 px */
|
|
54
|
+
.title {
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
padding-top: 5px;
|
|
57
|
+
margin-bottom: 3px;
|
|
58
|
+
font-size: 20px;
|
|
59
|
+
line-height: 26px;
|
|
60
|
+
color: #99c3ff;
|
|
61
|
+
text-overflow: ellipsis;
|
|
62
|
+
white-space: nowrap;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Two lines */
|
|
66
|
+
.text {
|
|
67
|
+
display: -webkit-box;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
font-size: 14px;
|
|
70
|
+
line-height: 22px;
|
|
71
|
+
color: #bdc1c6;
|
|
72
|
+
-webkit-box-orient: vertical;
|
|
73
|
+
-webkit-line-clamp: 2;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { html, nothing, unsafeCSS } from "lit"
|
|
2
|
+
|
|
3
|
+
import styles from "./google.css?inline"
|
|
4
|
+
import { truncate, truncateWidth } from "./page.ts"
|
|
5
|
+
import type { Platform } from "./platform.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A desktop search result in dark mode.
|
|
9
|
+
*
|
|
10
|
+
* - The title is the `<title>`. Google cuts it at about 600 px of text.
|
|
11
|
+
* - The description is the meta `description`. Google cuts it at about 160 characters.
|
|
12
|
+
* - The site name is `og:site_name`, or else the domain.
|
|
13
|
+
* - Google can change the title and the description for a search.
|
|
14
|
+
*/
|
|
15
|
+
export const google: Platform = {
|
|
16
|
+
name: "Google",
|
|
17
|
+
styles: unsafeCSS(styles),
|
|
18
|
+
render(page) {
|
|
19
|
+
const path = page.url.pathname.split("/").filter(Boolean)
|
|
20
|
+
return html`
|
|
21
|
+
<div class="google">
|
|
22
|
+
<div class="site">
|
|
23
|
+
<span class="favicon">
|
|
24
|
+
${page.favicon ? html`<img src=${page.favicon} alt="" />` : nothing}
|
|
25
|
+
</span>
|
|
26
|
+
<div class="source">
|
|
27
|
+
<div class="name">${page.og.siteName ?? page.url.host}</div>
|
|
28
|
+
<div class="url">${[page.url.origin, ...path].join(" › ")}</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="title">${truncateWidth(page.title, 600, "20px Arial")}</div>
|
|
32
|
+
<div class="text">${truncate(page.description, 160, " ...")}</div>
|
|
33
|
+
</div>
|
|
34
|
+
`
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* LinkedIn: a link preview in a post, 552 px wide */
|
|
2
|
+
.linkedin {
|
|
3
|
+
width: 100%;
|
|
4
|
+
max-width: 552px;
|
|
5
|
+
overflow: hidden;
|
|
6
|
+
font-family: -apple-system, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
7
|
+
border-radius: 8px;
|
|
8
|
+
background: #1b1f23;
|
|
9
|
+
|
|
10
|
+
&.large .image {
|
|
11
|
+
width: 100%;
|
|
12
|
+
aspect-ratio: 1.91 / 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* A small image: on the left of the text */
|
|
16
|
+
&.small {
|
|
17
|
+
display: flex;
|
|
18
|
+
align-items: center;
|
|
19
|
+
|
|
20
|
+
.image {
|
|
21
|
+
flex: none;
|
|
22
|
+
width: 128px;
|
|
23
|
+
height: 72px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.body {
|
|
27
|
+
flex: 1;
|
|
28
|
+
align-self: stretch;
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
justify-content: center;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.body {
|
|
36
|
+
min-width: 0;
|
|
37
|
+
padding: 8px 12px;
|
|
38
|
+
background: #38434f;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.title {
|
|
42
|
+
display: -webkit-box;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
font-size: 14px;
|
|
45
|
+
font-weight: 600;
|
|
46
|
+
line-height: 20px;
|
|
47
|
+
color: rgba(255, 255, 255, 0.9);
|
|
48
|
+
-webkit-box-orient: vertical;
|
|
49
|
+
-webkit-line-clamp: 2;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.host {
|
|
53
|
+
margin-top: 4px;
|
|
54
|
+
font-size: 12px;
|
|
55
|
+
line-height: 16px;
|
|
56
|
+
color: rgba(255, 255, 255, 0.6);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { html, unsafeCSS } from "lit"
|
|
2
|
+
|
|
3
|
+
import "../../ui/image/image.ts"
|
|
4
|
+
|
|
5
|
+
import styles from "./linkedin.css?inline"
|
|
6
|
+
import type { Platform } from "./platform.ts"
|
|
7
|
+
|
|
8
|
+
// An image narrower than this shows as a small image on the left
|
|
9
|
+
const LARGE_WIDTH = 401
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A link preview in a post in dark mode.
|
|
13
|
+
*
|
|
14
|
+
* - Uses `og:title` and `og:image`, or else the `<title>`. LinkedIn does not show the description.
|
|
15
|
+
* - An image narrower than 401 px shows as a small image on the left.
|
|
16
|
+
* - The title shows on two lines at most.
|
|
17
|
+
*/
|
|
18
|
+
export const linkedin: Platform = {
|
|
19
|
+
name: "LinkedIn",
|
|
20
|
+
styles: unsafeCSS(styles),
|
|
21
|
+
render(page) {
|
|
22
|
+
const width = page.og.imageWidth
|
|
23
|
+
const small = width !== undefined && width < LARGE_WIDTH
|
|
24
|
+
return html`
|
|
25
|
+
<div class="linkedin ${small ? "small" : "large"}">
|
|
26
|
+
<astro-metadata-image class="image" .src=${page.og.image}></astro-metadata-image>
|
|
27
|
+
<div class="body">
|
|
28
|
+
<div class="title">${page.og.title ?? page.title}</div>
|
|
29
|
+
<div class="host">${page.url.host}</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
`
|
|
33
|
+
},
|
|
34
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { findMeta } from "../../data.ts"
|
|
2
|
+
import type { PageMetadata } from "../../data.ts"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The values of the page that the platforms read for a link preview. Each platform
|
|
6
|
+
* picks its own values from these, with its own fallbacks.
|
|
7
|
+
*/
|
|
8
|
+
export type SharedPage = {
|
|
9
|
+
/** The URL of the page: the canonical URL, or the current URL. */
|
|
10
|
+
url: URL
|
|
11
|
+
/** The text of the `<title>` tag. */
|
|
12
|
+
title?: string
|
|
13
|
+
/** The meta description. */
|
|
14
|
+
description?: string
|
|
15
|
+
og: {
|
|
16
|
+
title?: string
|
|
17
|
+
description?: string
|
|
18
|
+
siteName?: string
|
|
19
|
+
image?: string
|
|
20
|
+
imageWidth?: number
|
|
21
|
+
imageHeight?: number
|
|
22
|
+
}
|
|
23
|
+
twitter: {
|
|
24
|
+
card?: string
|
|
25
|
+
title?: string
|
|
26
|
+
description?: string
|
|
27
|
+
image?: string
|
|
28
|
+
}
|
|
29
|
+
/** The first `theme-color` of the page. */
|
|
30
|
+
themeColor?: string
|
|
31
|
+
/** The favicon: the SVG, or else the largest icon. */
|
|
32
|
+
favicon?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns a number from a meta value, or undefined.
|
|
37
|
+
*/
|
|
38
|
+
function toNumber(value: string | undefined): number | undefined {
|
|
39
|
+
const number = Number.parseInt(value ?? "", 10)
|
|
40
|
+
return Number.isNaN(number) ? undefined : number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns the favicon: the SVG, or else the largest icon.
|
|
45
|
+
*/
|
|
46
|
+
function favicon(data: PageMetadata): string | undefined {
|
|
47
|
+
const icons = data.icons.filter((icon) => {
|
|
48
|
+
return icon.rel === "icon"
|
|
49
|
+
})
|
|
50
|
+
const svg = icons.find((icon) => {
|
|
51
|
+
return icon.type === "image/svg+xml"
|
|
52
|
+
})
|
|
53
|
+
const largest = [...icons].sort((a, b) => {
|
|
54
|
+
return (toNumber(b.sizes) ?? 0) - (toNumber(a.sizes) ?? 0)
|
|
55
|
+
})[0]
|
|
56
|
+
return (svg ?? largest)?.href
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reads the values of the page that the platforms use.
|
|
61
|
+
*/
|
|
62
|
+
export function sharedPage(data: PageMetadata): SharedPage {
|
|
63
|
+
const og = data.openGraph
|
|
64
|
+
const twitter = data.twitter
|
|
65
|
+
return {
|
|
66
|
+
url: new URL(data.canonical ?? location.href, location.href),
|
|
67
|
+
title: data.title,
|
|
68
|
+
description: data.description,
|
|
69
|
+
og: {
|
|
70
|
+
title: findMeta(og, "og:title"),
|
|
71
|
+
description: findMeta(og, "og:description"),
|
|
72
|
+
siteName: findMeta(og, "og:site_name"),
|
|
73
|
+
image: findMeta(og, "og:image"),
|
|
74
|
+
imageWidth: toNumber(findMeta(og, "og:image:width")),
|
|
75
|
+
imageHeight: toNumber(findMeta(og, "og:image:height")),
|
|
76
|
+
},
|
|
77
|
+
twitter: {
|
|
78
|
+
card: findMeta(twitter, "twitter:card"),
|
|
79
|
+
title: findMeta(twitter, "twitter:title"),
|
|
80
|
+
description: findMeta(twitter, "twitter:description"),
|
|
81
|
+
image: findMeta(twitter, "twitter:image"),
|
|
82
|
+
},
|
|
83
|
+
themeColor: data.themeColors[0]?.color,
|
|
84
|
+
favicon: favicon(data),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Cuts a text after a number of characters, at the end of a word, and adds an ellipsis.
|
|
90
|
+
*/
|
|
91
|
+
export function truncate(text: string | undefined, max: number, ellipsis = "…"): string {
|
|
92
|
+
if (!text || text.length <= max) return text ?? ""
|
|
93
|
+
|
|
94
|
+
const cut = text.slice(0, max - ellipsis.length)
|
|
95
|
+
const space = cut.lastIndexOf(" ")
|
|
96
|
+
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}${ellipsis}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let canvas: HTMLCanvasElement | undefined
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Cuts a text at a width in pixels, the same as a platform that measures the text,
|
|
103
|
+
* e.g. Google cuts a title at about 600 px of 20 px Arial.
|
|
104
|
+
*/
|
|
105
|
+
export function truncateWidth(
|
|
106
|
+
text: string | undefined,
|
|
107
|
+
width: number,
|
|
108
|
+
font: string,
|
|
109
|
+
ellipsis = "..."
|
|
110
|
+
): string {
|
|
111
|
+
if (!text) return ""
|
|
112
|
+
|
|
113
|
+
canvas ??= document.createElement("canvas")
|
|
114
|
+
const context = canvas.getContext("2d")
|
|
115
|
+
if (!context) return text
|
|
116
|
+
context.font = font
|
|
117
|
+
if (context.measureText(text).width <= width) return text
|
|
118
|
+
|
|
119
|
+
// The longest start of the text that fits with the ellipsis
|
|
120
|
+
let low = 0
|
|
121
|
+
let high = text.length
|
|
122
|
+
while (low < high) {
|
|
123
|
+
const middle = Math.ceil((low + high) / 2)
|
|
124
|
+
const fits = context.measureText(text.slice(0, middle) + ellipsis).width <= width
|
|
125
|
+
if (fits) low = middle
|
|
126
|
+
else high = middle - 1
|
|
127
|
+
}
|
|
128
|
+
return `${text.slice(0, low).trimEnd()}${ellipsis}`
|
|
129
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CSSResult, TemplateResult } from "lit"
|
|
2
|
+
|
|
3
|
+
import type { SharedPage } from "./page.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A platform that shows a link preview, e.g. Google or Discord. To add a platform,
|
|
7
|
+
* add a module with a `Platform` object, and add it to the list in `previews-view.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export type Platform = {
|
|
10
|
+
name: string
|
|
11
|
+
/** The CSS of the preview. The colors are the dark mode colors of the platform. */
|
|
12
|
+
styles: CSSResult
|
|
13
|
+
render(page: SharedPage): TemplateResult
|
|
14
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The previews copy the dark mode of each platform: their own fonts, sizes and colors.
|
|
3
|
+
* These are the only colors outside the Tailwind scales, because the previews must
|
|
4
|
+
* look like the platform, not like the panel.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* The preview at the center of a dotted background */
|
|
8
|
+
.stage {
|
|
9
|
+
display: flex;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
padding: var(--space-600) var(--space-400);
|
|
12
|
+
border: 1px solid var(--zinc-800);
|
|
13
|
+
border-radius: var(--radius);
|
|
14
|
+
background-color: var(--zinc-950);
|
|
15
|
+
background-image: radial-gradient(var(--zinc-700) 1px, transparent 1px);
|
|
16
|
+
background-size: 12px 12px;
|
|
17
|
+
|
|
18
|
+
/* A preview has its own box-sizing, the same as the platform */
|
|
19
|
+
* {
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* A missing image, or an image that does not load */
|
|
25
|
+
.stage .image[missing],
|
|
26
|
+
.stage .thumbnail[missing] {
|
|
27
|
+
font-size: 13px;
|
|
28
|
+
color: #71767b;
|
|
29
|
+
background: #16181c;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* A message instead of the preview, e.g. without twitter:card */
|
|
33
|
+
.notice {
|
|
34
|
+
margin: 0;
|
|
35
|
+
color: var(--zinc-400);
|
|
36
|
+
text-align: center;
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { html, LitElement, unsafeCSS } from "lit"
|
|
2
|
+
|
|
3
|
+
import type { PageMetadata } from "../../data.ts"
|
|
4
|
+
import { define } from "../../define.ts"
|
|
5
|
+
import shared from "../../styles/shared.css?inline"
|
|
6
|
+
|
|
7
|
+
import "../../ui/section/section.ts"
|
|
8
|
+
|
|
9
|
+
import { discord } from "./discord.ts"
|
|
10
|
+
import { facebook } from "./facebook.ts"
|
|
11
|
+
import { google } from "./google.ts"
|
|
12
|
+
import { linkedin } from "./linkedin.ts"
|
|
13
|
+
import { sharedPage } from "./page.ts"
|
|
14
|
+
import type { Platform } from "./platform.ts"
|
|
15
|
+
import styles from "./previews-view.css?inline"
|
|
16
|
+
import { slack } from "./slack.ts"
|
|
17
|
+
import { twitter } from "./twitter.ts"
|
|
18
|
+
import { whatsapp } from "./whatsapp.ts"
|
|
19
|
+
|
|
20
|
+
// The platforms in the order of the sections
|
|
21
|
+
const PLATFORMS: Platform[] = [google, twitter, facebook, linkedin, whatsapp, discord, slack]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The page as each platform shows its link: a search result, social cards and chat previews.
|
|
25
|
+
* The previews copy the dark mode, the sizes and the rules of each platform. The platforms
|
|
26
|
+
* render the real previews on their own servers, so a preview can differ a little.
|
|
27
|
+
*/
|
|
28
|
+
export class PreviewsView extends LitElement {
|
|
29
|
+
static properties = { data: { attribute: false } }
|
|
30
|
+
static styles = [
|
|
31
|
+
unsafeCSS(shared),
|
|
32
|
+
unsafeCSS(styles),
|
|
33
|
+
...PLATFORMS.map((platform) => {
|
|
34
|
+
return platform.styles
|
|
35
|
+
}),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
declare data: PageMetadata
|
|
39
|
+
|
|
40
|
+
render() {
|
|
41
|
+
const page = sharedPage(this.data)
|
|
42
|
+
return PLATFORMS.map((platform) => {
|
|
43
|
+
return html`
|
|
44
|
+
<astro-metadata-section heading=${platform.name}>
|
|
45
|
+
<div class="stage">${platform.render(page)}</div>
|
|
46
|
+
</astro-metadata-section>
|
|
47
|
+
`
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
define("astro-metadata-previews", PreviewsView)
|