@tangle-network/brand 1.26.1 → 1.27.0
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/CHANGELOG.md +12 -0
- package/README.md +18 -0
- package/bin/tangle-drift.mjs +7 -5
- package/dist/social.d.ts +40 -0
- package/dist/social.js +109 -0
- package/package.json +6 -1
- package/src/social.ts +93 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.27.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3527225: Publish framework-neutral social metadata for server-rendered and static product pages. Keep the poster fallback, canonical URL, alt text and optional video properties together without importing React or claiming social-client playback.
|
|
8
|
+
|
|
9
|
+
## 1.26.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 916d254: Scope GTM design drift to application UI by excluding its customer ad-renderer assets. Add owning-surface exclusions without changing drift baselines or allowing consumer overrides.
|
|
14
|
+
|
|
3
15
|
## 1.26.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -10,6 +10,24 @@ pnpm add @tangle-network/brand
|
|
|
10
10
|
|
|
11
11
|
## Use
|
|
12
12
|
|
|
13
|
+
### Social previews
|
|
14
|
+
|
|
15
|
+
`@tangle-network/brand/social` is framework-neutral. Pass the page's existing
|
|
16
|
+
copy and verified media to `socialMetadata({ url, title, description, image,
|
|
17
|
+
video? })`. The image requires its public HTTPS `url`, MIME `type`, measured
|
|
18
|
+
pixel `width`/`height` and descriptive `alt`; an optional video requires the same
|
|
19
|
+
media fields except alt. Render the returned `tags` in the server/static head
|
|
20
|
+
and use `canonicalUrl` for the canonical link. Each tag has either `property`
|
|
21
|
+
or `name`, plus `content`; the consumer's HTML renderer owns escaping.
|
|
22
|
+
|
|
23
|
+
Keep product-specific composition and media approval with their existing
|
|
24
|
+
owners. Brand owns the common tag contract; callers supply the approved asset
|
|
25
|
+
URLs. The default X card retains the image fallback. Do not infer video playback
|
|
26
|
+
from metadata or switch to a platform-specific player without that platform's
|
|
27
|
+
current supported contract and a real preview check. This helper does not fetch
|
|
28
|
+
media, prove its MIME/codec/duration/range/cache behavior, or approve publication.
|
|
29
|
+
Verify those against the served files. Canonical URLs omit query and fragment.
|
|
30
|
+
|
|
13
31
|
### Tailwind v4
|
|
14
32
|
|
|
15
33
|
```css
|
package/bin/tangle-drift.mjs
CHANGED
|
@@ -34,7 +34,8 @@ export const SURFACES = {
|
|
|
34
34
|
sandbox: { repo: "tangle-network/agent-container", roots: ["products/sandbox/web"] },
|
|
35
35
|
platform: { repo: "tangle-network/agent-container", roots: ["products/platform/web"] },
|
|
36
36
|
intelligence: { repo: "tangle-network/agent-container", roots: ["products/intelligence/web"] },
|
|
37
|
-
|
|
37
|
+
// These raw renderer assets run only inside customer ad HTML, never the app UI.
|
|
38
|
+
gtm: { repo: "tangle-network/gtm-agent", roots: [""], exclude: [":!src/lib/.server/sandbox/creative/*"] },
|
|
38
39
|
tax: { repo: "tangle-network/tax-agent", roots: ["apps/web"] },
|
|
39
40
|
legal: { repo: "tangle-network/legal-agent", roots: [""] },
|
|
40
41
|
insurance: { repo: "tangle-network/insurance-agent", roots: [""] },
|
|
@@ -213,7 +214,7 @@ export function grepMatches(source, roots, pattern, exts) {
|
|
|
213
214
|
const args = ["grep", "-I", "-P", "-n", "-o", "--null", "-e", pattern];
|
|
214
215
|
if (source.rev) args.push(source.rev);
|
|
215
216
|
else args.push("--untracked");
|
|
216
|
-
args.push("--", ...pathspecs(roots, exts), ...EXCLUDE);
|
|
217
|
+
args.push("--", ...pathspecs(roots, exts), ...EXCLUDE, ...(source.exclude ?? []));
|
|
217
218
|
const prefix = source.rev ? `${source.rev}:` : "";
|
|
218
219
|
const out = git(source.dir, args, { allowExit1: true });
|
|
219
220
|
const rows = [];
|
|
@@ -230,7 +231,7 @@ function grepFiles(source, pattern, specs) {
|
|
|
230
231
|
const args = ["grep", "-I", "-P", "-l", "--null", "-e", pattern];
|
|
231
232
|
if (source.rev) args.push(source.rev);
|
|
232
233
|
else args.push("--untracked");
|
|
233
|
-
args.push("--", ...specs, ...EXCLUDE);
|
|
234
|
+
args.push("--", ...specs, ...EXCLUDE, ...(source.exclude ?? []));
|
|
234
235
|
const prefix = source.rev ? `${source.rev}:` : "";
|
|
235
236
|
return git(source.dir, args, { allowExit1: true }).split("\0").filter(Boolean)
|
|
236
237
|
.map((f) => (f.startsWith(prefix) ? f.slice(prefix.length) : f).trim()).filter(Boolean);
|
|
@@ -372,14 +373,15 @@ function pkgVersions(source, roots) {
|
|
|
372
373
|
|
|
373
374
|
/** Measure one surface from `source`. `matches` holds the raw gated matches for reporting. */
|
|
374
375
|
export function measure(surface, source) {
|
|
375
|
-
const { roots, extraExt } = SURFACES[surface];
|
|
376
|
+
const { roots, extraExt, exclude = [] } = SURFACES[surface];
|
|
377
|
+
source = { ...source, exclude };
|
|
376
378
|
const files = listFiles(source, roots);
|
|
377
379
|
const srcRe = extraExt ? /\.(tsx|jsx|astro|vue|svelte|js|html)$/ : /\.(tsx|jsx|astro|vue|svelte)$/;
|
|
378
380
|
const tsxFiles = files.filter((f) => srcRe.test(f) && !/node_modules|test|spec|stories|fixtures|dist\//.test(f)).length;
|
|
379
381
|
const cssFiles = files.filter((f) => f.endsWith(".css") && !f.includes("node_modules") && !f.includes("dist/")).length;
|
|
380
382
|
|
|
381
383
|
const matches = {};
|
|
382
|
-
const row = { repo: SURFACES[surface].repo, roots, branch: source.branch ?? null, head: source.head ?? null,
|
|
384
|
+
const row = { repo: SURFACES[surface].repo, roots, exclude, branch: source.branch ?? null, head: source.head ?? null,
|
|
383
385
|
tsx_files: tsxFiles, css_files: cssFiles, versions: pkgVersions(source, roots), importing_files: {} };
|
|
384
386
|
for (const pkg of SHARED) {
|
|
385
387
|
const escaped = pkg.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
|
package/dist/social.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/social.d.ts
|
|
2
|
+
export interface SocialImage {
|
|
3
|
+
url: string;
|
|
4
|
+
type: "image/png" | "image/jpeg" | "image/webp";
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
alt: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SocialVideo {
|
|
10
|
+
url: string;
|
|
11
|
+
type: "video/mp4" | "video/webm";
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
export interface SocialPage {
|
|
16
|
+
url: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
image: SocialImage;
|
|
20
|
+
video?: SocialVideo;
|
|
21
|
+
type?: "website" | "article";
|
|
22
|
+
siteName?: string;
|
|
23
|
+
}
|
|
24
|
+
export type SocialMetaTag = {
|
|
25
|
+
property: string;
|
|
26
|
+
content: string;
|
|
27
|
+
name?: never;
|
|
28
|
+
} | {
|
|
29
|
+
name: string;
|
|
30
|
+
content: string;
|
|
31
|
+
property?: never;
|
|
32
|
+
};
|
|
33
|
+
/** Render these tags in the server/static head, never after hydration.
|
|
34
|
+
* Video metadata advertises an asset; a social client's playback needs its own proof.
|
|
35
|
+
*/
|
|
36
|
+
export declare function socialMetadata(page: SocialPage): {
|
|
37
|
+
canonicalUrl: string;
|
|
38
|
+
tags: SocialMetaTag[];
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
package/dist/social.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
//#region src/social.ts
|
|
2
|
+
function httpsUrl(value) {
|
|
3
|
+
const url = new URL(value);
|
|
4
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("Social metadata requires HTTPS URLs without credentials");
|
|
5
|
+
return url;
|
|
6
|
+
}
|
|
7
|
+
function mediaTags(kind, media) {
|
|
8
|
+
const url = httpsUrl(media.url).href;
|
|
9
|
+
if (![media.width, media.height].every((size) => Number.isSafeInteger(size) && size > 0)) throw new Error(`Social ${kind} dimensions must be positive pixel counts`);
|
|
10
|
+
if (!(kind === "image" ? [
|
|
11
|
+
"image/png",
|
|
12
|
+
"image/jpeg",
|
|
13
|
+
"image/webp"
|
|
14
|
+
] : ["video/mp4", "video/webm"]).includes(media.type)) throw new Error(`Unsupported social ${kind} MIME type`);
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
property: `og:${kind}`,
|
|
18
|
+
content: url
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
property: `og:${kind}:secure_url`,
|
|
22
|
+
content: url
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
property: `og:${kind}:type`,
|
|
26
|
+
content: media.type
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
property: `og:${kind}:width`,
|
|
30
|
+
content: String(media.width)
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
property: `og:${kind}:height`,
|
|
34
|
+
content: String(media.height)
|
|
35
|
+
}
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
/** Render these tags in the server/static head, never after hydration.
|
|
39
|
+
* Video metadata advertises an asset; a social client's playback needs its own proof.
|
|
40
|
+
*/
|
|
41
|
+
function socialMetadata(page) {
|
|
42
|
+
for (const text of [
|
|
43
|
+
page.title,
|
|
44
|
+
page.description,
|
|
45
|
+
page.image.alt
|
|
46
|
+
]) if (!text.trim()) throw new Error("Social title, description and image alt must be nonempty");
|
|
47
|
+
const canonical = httpsUrl(page.url);
|
|
48
|
+
canonical.search = "";
|
|
49
|
+
canonical.hash = "";
|
|
50
|
+
const canonicalUrl = canonical.href;
|
|
51
|
+
const imageUrl = httpsUrl(page.image.url).href;
|
|
52
|
+
return {
|
|
53
|
+
canonicalUrl,
|
|
54
|
+
tags: [
|
|
55
|
+
{
|
|
56
|
+
property: "og:type",
|
|
57
|
+
content: page.type ?? "website"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
property: "og:site_name",
|
|
61
|
+
content: page.siteName ?? "Tangle"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
property: "og:url",
|
|
65
|
+
content: canonicalUrl
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
property: "og:title",
|
|
69
|
+
content: page.title
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
property: "og:description",
|
|
73
|
+
content: page.description
|
|
74
|
+
},
|
|
75
|
+
...mediaTags("image", page.image),
|
|
76
|
+
{
|
|
77
|
+
property: "og:image:alt",
|
|
78
|
+
content: page.image.alt
|
|
79
|
+
},
|
|
80
|
+
...page.video ? mediaTags("video", page.video) : [],
|
|
81
|
+
{
|
|
82
|
+
name: "twitter:card",
|
|
83
|
+
content: "summary_large_image"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "twitter:site",
|
|
87
|
+
content: "@tangle_network"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "twitter:title",
|
|
91
|
+
content: page.title
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "twitter:description",
|
|
95
|
+
content: page.description
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "twitter:image",
|
|
99
|
+
content: imageUrl
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "twitter:image:alt",
|
|
103
|
+
content: page.image.alt
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { socialMetadata };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/brand",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Tangle brand system — design tokens, logo, Tailwind v4 theme. Single source of truth for every Tangle app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,6 +50,11 @@
|
|
|
50
50
|
"import": "./dist/knot.js",
|
|
51
51
|
"default": "./dist/knot.js"
|
|
52
52
|
},
|
|
53
|
+
"./social": {
|
|
54
|
+
"types": "./dist/social.d.ts",
|
|
55
|
+
"import": "./dist/social.js",
|
|
56
|
+
"default": "./dist/social.js"
|
|
57
|
+
},
|
|
53
58
|
"./styles/presentation.css": "./src/styles/presentation.css"
|
|
54
59
|
},
|
|
55
60
|
"peerDependencies": {
|
package/src/social.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface SocialImage {
|
|
2
|
+
url: string;
|
|
3
|
+
type: "image/png" | "image/jpeg" | "image/webp";
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
alt: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SocialVideo {
|
|
10
|
+
url: string;
|
|
11
|
+
type: "video/mp4" | "video/webm";
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SocialPage {
|
|
17
|
+
url: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
image: SocialImage;
|
|
21
|
+
video?: SocialVideo;
|
|
22
|
+
type?: "website" | "article";
|
|
23
|
+
siteName?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SocialMetaTag =
|
|
27
|
+
| { property: string; content: string; name?: never }
|
|
28
|
+
| { name: string; content: string; property?: never };
|
|
29
|
+
|
|
30
|
+
function httpsUrl(value: string): URL {
|
|
31
|
+
const url = new URL(value);
|
|
32
|
+
if (url.protocol !== "https:" || url.username || url.password) {
|
|
33
|
+
throw new Error("Social metadata requires HTTPS URLs without credentials");
|
|
34
|
+
}
|
|
35
|
+
return url;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mediaTags(
|
|
39
|
+
kind: "image" | "video",
|
|
40
|
+
media: SocialImage | SocialVideo,
|
|
41
|
+
): SocialMetaTag[] {
|
|
42
|
+
const url = httpsUrl(media.url).href;
|
|
43
|
+
if (![media.width, media.height].every((size) => Number.isSafeInteger(size) && size > 0)) {
|
|
44
|
+
throw new Error(`Social ${kind} dimensions must be positive pixel counts`);
|
|
45
|
+
}
|
|
46
|
+
const types = kind === "image"
|
|
47
|
+
? ["image/png", "image/jpeg", "image/webp"]
|
|
48
|
+
: ["video/mp4", "video/webm"];
|
|
49
|
+
if (!types.includes(media.type)) throw new Error(`Unsupported social ${kind} MIME type`);
|
|
50
|
+
return [
|
|
51
|
+
{ property: `og:${kind}`, content: url },
|
|
52
|
+
{ property: `og:${kind}:secure_url`, content: url },
|
|
53
|
+
{ property: `og:${kind}:type`, content: media.type },
|
|
54
|
+
{ property: `og:${kind}:width`, content: String(media.width) },
|
|
55
|
+
{ property: `og:${kind}:height`, content: String(media.height) },
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Render these tags in the server/static head, never after hydration.
|
|
60
|
+
* Video metadata advertises an asset; a social client's playback needs its own proof.
|
|
61
|
+
*/
|
|
62
|
+
export function socialMetadata(page: SocialPage): {
|
|
63
|
+
canonicalUrl: string;
|
|
64
|
+
tags: SocialMetaTag[];
|
|
65
|
+
} {
|
|
66
|
+
for (const text of [page.title, page.description, page.image.alt]) {
|
|
67
|
+
if (!text.trim()) throw new Error("Social title, description and image alt must be nonempty");
|
|
68
|
+
}
|
|
69
|
+
const canonical = httpsUrl(page.url);
|
|
70
|
+
canonical.search = "";
|
|
71
|
+
canonical.hash = "";
|
|
72
|
+
const canonicalUrl = canonical.href;
|
|
73
|
+
const imageUrl = httpsUrl(page.image.url).href;
|
|
74
|
+
return {
|
|
75
|
+
canonicalUrl,
|
|
76
|
+
tags: [
|
|
77
|
+
{ property: "og:type", content: page.type ?? "website" },
|
|
78
|
+
{ property: "og:site_name", content: page.siteName ?? "Tangle" },
|
|
79
|
+
{ property: "og:url", content: canonicalUrl },
|
|
80
|
+
{ property: "og:title", content: page.title },
|
|
81
|
+
{ property: "og:description", content: page.description },
|
|
82
|
+
...mediaTags("image", page.image),
|
|
83
|
+
{ property: "og:image:alt", content: page.image.alt },
|
|
84
|
+
...(page.video ? mediaTags("video", page.video) : []),
|
|
85
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
86
|
+
{ name: "twitter:site", content: "@tangle_network" },
|
|
87
|
+
{ name: "twitter:title", content: page.title },
|
|
88
|
+
{ name: "twitter:description", content: page.description },
|
|
89
|
+
{ name: "twitter:image", content: imageUrl },
|
|
90
|
+
{ name: "twitter:image:alt", content: page.image.alt },
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
}
|