@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.
Files changed (102) hide show
  1. package/README.md +427 -82
  2. package/package.json +25 -14
  3. package/src/assets/icons/LICENSE.md +35 -0
  4. package/src/assets/icons/binoculars.svg +1 -0
  5. package/src/assets/icons/caret-right.svg +1 -0
  6. package/src/assets/icons/check.svg +1 -0
  7. package/src/assets/icons/copy.svg +1 -0
  8. package/src/assets/icons/seal-check-duotone.svg +1 -0
  9. package/src/assets/icons/seal-question-duotone.svg +1 -0
  10. package/src/assets/icons/warning-circle-duotone.svg +1 -0
  11. package/src/assets/icons/warning-duotone.svg +1 -0
  12. package/src/assets/icons/x.svg +1 -0
  13. package/src/components/Metadata.astro +145 -0
  14. package/src/components/index.ts +2 -0
  15. package/src/integration.d.ts +112 -0
  16. package/src/integration.js +508 -0
  17. package/src/lib/checks.d.ts +70 -0
  18. package/src/lib/checks.js +193 -0
  19. package/src/lib/config.ts +111 -0
  20. package/src/lib/favicon-files.d.ts +29 -0
  21. package/src/lib/favicon-files.js +103 -0
  22. package/src/lib/favicon-image.ts +124 -0
  23. package/src/lib/favicon.ts +167 -0
  24. package/src/lib/head.d.ts +25 -0
  25. package/src/lib/head.js +141 -0
  26. package/src/lib/manifest.ts +126 -0
  27. package/src/lib/rules.d.ts +73 -0
  28. package/src/lib/rules.js +902 -0
  29. package/src/lib/schema.ts +208 -0
  30. package/src/lib/site.ts +72 -0
  31. package/src/lib/url.ts +79 -0
  32. package/src/routes/favicon.ts +14 -0
  33. package/src/routes/manifest.ts +18 -0
  34. package/src/runtime.ts +16 -0
  35. package/src/toolbar/app.ts +72 -0
  36. package/src/toolbar/assets.ts +81 -0
  37. package/src/toolbar/data.ts +282 -0
  38. package/src/toolbar/define.ts +7 -0
  39. package/src/toolbar/env.d.ts +10 -0
  40. package/src/toolbar/highlight.ts +83 -0
  41. package/src/toolbar/styles/shared.css +18 -0
  42. package/src/toolbar/styles/tokens.css +182 -0
  43. package/src/toolbar/toolbar-badge.ts +78 -0
  44. package/src/toolbar/ui/alert/alert.css +126 -0
  45. package/src/toolbar/ui/alert/alert.ts +102 -0
  46. package/src/toolbar/ui/badge/badge.css +41 -0
  47. package/src/toolbar/ui/badge/badge.ts +24 -0
  48. package/src/toolbar/ui/code-block/code-block.css +90 -0
  49. package/src/toolbar/ui/code-block/code-block.ts +68 -0
  50. package/src/toolbar/ui/color-swatch/color-swatch.css +15 -0
  51. package/src/toolbar/ui/color-swatch/color-swatch.ts +31 -0
  52. package/src/toolbar/ui/icon/icon.css +11 -0
  53. package/src/toolbar/ui/icon/icon.ts +46 -0
  54. package/src/toolbar/ui/image/image.css +18 -0
  55. package/src/toolbar/ui/image/image.ts +58 -0
  56. package/src/toolbar/ui/inline-code/inline-code.css +16 -0
  57. package/src/toolbar/ui/inline-code/inline-code.ts +41 -0
  58. package/src/toolbar/ui/section/section.css +88 -0
  59. package/src/toolbar/ui/section/section.ts +78 -0
  60. package/src/toolbar/ui/tab/tab.css +7 -0
  61. package/src/toolbar/ui/tab/tab.ts +47 -0
  62. package/src/toolbar/ui/tab-group/tab-group.css +42 -0
  63. package/src/toolbar/ui/tab-group/tab-group.ts +101 -0
  64. package/src/toolbar/ui/table/table.css +124 -0
  65. package/src/toolbar/ui/table/table.ts +155 -0
  66. package/src/toolbar/views/code-blocks.ts +35 -0
  67. package/src/toolbar/views/metadata-panel.css +101 -0
  68. package/src/toolbar/views/metadata-panel.ts +125 -0
  69. package/src/toolbar/views/overview-view.css +27 -0
  70. package/src/toolbar/views/overview-view.ts +401 -0
  71. package/src/toolbar/views/previews/discord.css +70 -0
  72. package/src/toolbar/views/previews/discord.ts +60 -0
  73. package/src/toolbar/views/previews/facebook.css +65 -0
  74. package/src/toolbar/views/previews/facebook.ts +36 -0
  75. package/src/toolbar/views/previews/google.css +75 -0
  76. package/src/toolbar/views/previews/google.ts +36 -0
  77. package/src/toolbar/views/previews/linkedin.css +58 -0
  78. package/src/toolbar/views/previews/linkedin.ts +34 -0
  79. package/src/toolbar/views/previews/page.ts +129 -0
  80. package/src/toolbar/views/previews/platform.ts +14 -0
  81. package/src/toolbar/views/previews/previews-view.css +37 -0
  82. package/src/toolbar/views/previews/previews-view.ts +52 -0
  83. package/src/toolbar/views/previews/slack.css +61 -0
  84. package/src/toolbar/views/previews/slack.ts +47 -0
  85. package/src/toolbar/views/previews/twitter.css +89 -0
  86. package/src/toolbar/views/previews/twitter.ts +57 -0
  87. package/src/toolbar/views/previews/whatsapp.css +88 -0
  88. package/src/toolbar/views/previews/whatsapp.ts +65 -0
  89. package/src/toolbar/views/raw-view.ts +41 -0
  90. package/index.ts +0 -31
  91. package/src/components/Canonical.astro +0 -11
  92. package/src/components/Description.astro +0 -9
  93. package/src/components/Favicon.astro +0 -96
  94. package/src/components/Head.astro +0 -94
  95. package/src/components/Keywords.astro +0 -9
  96. package/src/components/LanguageAlternates.astro +0 -18
  97. package/src/components/OpenGraph.astro +0 -75
  98. package/src/components/Robots.astro +0 -25
  99. package/src/components/Schema.astro +0 -18
  100. package/src/components/Title.astro +0 -13
  101. package/src/components/Twitter.astro +0 -27
  102. 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)