@uniflowed/router 0.0.0-alpha.10 → 0.0.0-alpha.11

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/index.js CHANGED
@@ -37,6 +37,7 @@ export type {
37
37
  RouteTable,
38
38
  Router,
39
39
  SearchParams,
40
+ TwitterCard,
40
41
  } from "./internal/runtime.js";
41
42
 
42
43
  export {
@@ -131,15 +131,54 @@ export type LoadingModule = {
131
131
  ...
132
132
  };
133
133
 
134
+ /**
135
+ * How a Twitter card is laid out, which is the whole of what `card` may be.
136
+ *
137
+ * A union rather than a string: every one of the four is spelled exactly this
138
+ * way and a fifth value is silently ignored by the crawler, so a typo in it
139
+ * costs a card and produces no error anywhere.
140
+ */
141
+ export type TwitterCard = "summary" | "summary_large_image" | "app" | "player";
142
+
134
143
  /** Document metadata a page or layout declares. */
135
144
  export type Metadata = {
136
145
  readonly title?: string,
137
146
  readonly description?: string,
147
+ /**
148
+ * The absolute URL every other URL here is resolved against.
149
+ *
150
+ * Open Graph and Twitter both require absolute image URLs, and a route
151
+ * module has no way to know the host it will be served from — so without
152
+ * this, `openGraph.images: ["/og.png"]` ships exactly as written and is not
153
+ * a valid `og:image`. Declare it once on the root layout and every
154
+ * descendant inherits it through the same merge as everything else.
155
+ *
156
+ * Resolution is the URL standard's, so `"/og.png"` is resolved against the
157
+ * *origin* and `"og.png"` against the base's own path — not against the
158
+ * page's URL, which `Head` does not know.
159
+ */
160
+ readonly metadataBase?: string,
161
+ /**
162
+ * This page's canonical URL, for `<link rel="canonical">` and `og:url`.
163
+ *
164
+ * Relative to `metadataBase` when it is not absolute. A page
165
+ * reachable at more than one path — a query a filter added, a duplicate
166
+ * under a second section — is one page, and this is how it says so.
167
+ */
168
+ readonly canonical?: string,
138
169
  readonly openGraph?: {
139
170
  readonly title?: string,
140
171
  readonly description?: string,
141
172
  readonly images?: $ReadOnlyArray<string>,
142
173
  },
174
+ readonly twitter?: {
175
+ readonly card?: TwitterCard,
176
+ readonly site?: string,
177
+ readonly creator?: string,
178
+ readonly title?: string,
179
+ readonly description?: string,
180
+ readonly images?: $ReadOnlyArray<string>,
181
+ },
143
182
  };
144
183
 
145
184
  /** Arguments a loader receives. */
@@ -1543,18 +1582,66 @@ function renderable<TProps extends { ... }>(
1543
1582
  return component as any;
1544
1583
  }
1545
1584
 
1585
+ /**
1586
+ * One URL from a route's metadata, made absolute if it can be.
1587
+ *
1588
+ * Open Graph, Twitter and `rel="canonical"` all want an absolute URL, and a
1589
+ * route module cannot know the host it is served from — so `metadataBase` is
1590
+ * how a site says it once, and this is where it is applied.
1591
+ *
1592
+ * Three things it deliberately does not do. It does not resolve against the
1593
+ * *page's* URL: `Head` renders inside the route and does not know it, and a
1594
+ * `metadataBase` is a site-wide fact rather than a per-page one. It does not
1595
+ * invent a base: with none declared the value is emitted exactly as written,
1596
+ * which is what every page that predates this field already gets. And it does
1597
+ * not throw — a `metadataBase` that is not a URL is a mistake in one field,
1598
+ * and turning it into a blank page would be a worse answer than an unresolved
1599
+ * `og:image`.
1600
+ */
1601
+ function absoluteUrl(value: string, base: void | string): string {
1602
+ if (base == null) return value;
1603
+ try {
1604
+ return new URL(value, base).href;
1605
+ } catch {
1606
+ return value;
1607
+ }
1608
+ }
1609
+
1546
1610
  component Head(metadata: Metadata) {
1547
- const { title, description, openGraph } = metadata;
1611
+ const { title, description, metadataBase, canonical, openGraph, twitter } = metadata;
1612
+ const href = canonical != null ? absoluteUrl(canonical, metadataBase) : null;
1548
1613
  return (
1549
1614
  <>
1550
1615
  {title != null ? <title>{title}</title> : null}
1551
1616
  {description != null ? <meta name="description" content={description} /> : null}
1617
+ {href != null ? <link rel="canonical" href={href} /> : null}
1618
+ {/* `og:url` *is* the canonical URL of the page, in Open Graph's own
1619
+ words, so one declaration answers both rather than asking a project
1620
+ to write the same URL twice and keep them in step. */}
1621
+ {href != null ? <meta property="og:url" content={href} /> : null}
1552
1622
  {openGraph?.title != null ? <meta property="og:title" content={openGraph.title} /> : null}
1553
1623
  {openGraph?.description != null ? (
1554
1624
  <meta property="og:description" content={openGraph.description} />
1555
1625
  ) : null}
1556
1626
  {openGraph?.images != null
1557
- ? openGraph.images.map((image) => <meta key={image} property="og:image" content={image} />)
1627
+ ? openGraph.images.map((image) => (
1628
+ <meta key={image} property="og:image" content={absoluteUrl(image, metadataBase)} />
1629
+ ))
1630
+ : null}
1631
+ {/* `name`, not `property`: Open Graph is RDFa and Twitter's cards are
1632
+ not, and a `property="twitter:card"` is ignored by the crawler that
1633
+ reads it. */}
1634
+ {twitter?.card != null ? <meta name="twitter:card" content={twitter.card} /> : null}
1635
+ {twitter?.site != null ? <meta name="twitter:site" content={twitter.site} /> : null}
1636
+ {twitter?.creator != null ? <meta name="twitter:creator" content={twitter.creator} /> : null}
1637
+ {twitter?.title != null ? <meta name="twitter:title" content={twitter.title} /> : null}
1638
+ {twitter?.description != null ? (
1639
+ <meta name="twitter:description" content={twitter.description} />
1640
+ ) : null}
1641
+ {twitter?.images != null
1642
+ ? twitter.images.map((image) => (
1643
+ <meta key={image} name="twitter:image" content={absoluteUrl(image, metadataBase)} />
1644
+ ))
1558
1645
  : null}
1559
1646
  </>
1560
1647
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.10",
3
+ "version": "0.0.0-alpha.11",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,6 @@
31
31
  "react-dom": ">=19"
32
32
  },
33
33
  "dependencies": {
34
- "@uniflowed/server": "0.0.0-alpha.10"
34
+ "@uniflowed/server": "0.0.0-alpha.11"
35
35
  }
36
36
  }