wand-kit 0.3.3 → 0.5.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/PROMPT.md +182 -16
- package/SKILL.md +91 -18
- package/START-HERE.md +12 -1
- package/brands/interim/assets/avatar.svg +1 -0
- package/brands/interim/assets/closing-art.png +0 -0
- package/brands/interim/assets/cover-art.png +0 -0
- package/brands/interim/assets/cover-portrait-art.png +0 -0
- package/brands/interim/assets/divider-art.png +0 -0
- package/brands/interim/assets/glyphs/README.md +29 -0
- package/brands/interim/assets/glyphs/activity.svg +15 -0
- package/brands/interim/assets/glyphs/atom.svg +17 -0
- package/brands/interim/assets/glyphs/badge-check.svg +16 -0
- package/brands/interim/assets/glyphs/bell.svg +16 -0
- package/brands/interim/assets/glyphs/blocks.svg +16 -0
- package/brands/interim/assets/glyphs/book-open.svg +16 -0
- package/brands/interim/assets/glyphs/bot.svg +20 -0
- package/brands/interim/assets/glyphs/boxes.svg +26 -0
- package/brands/interim/assets/glyphs/brain.svg +23 -0
- package/brands/interim/assets/glyphs/briefcase.svg +16 -0
- package/brands/interim/assets/glyphs/building-2.svg +21 -0
- package/brands/interim/assets/glyphs/calendar.svg +18 -0
- package/brands/interim/assets/glyphs/chart-column.svg +18 -0
- package/brands/interim/assets/glyphs/chart-line.svg +16 -0
- package/brands/interim/assets/glyphs/chart-pie.svg +16 -0
- package/brands/interim/assets/glyphs/circle-check.svg +16 -0
- package/brands/interim/assets/glyphs/clipboard-check.svg +17 -0
- package/brands/interim/assets/glyphs/clock.svg +16 -0
- package/brands/interim/assets/glyphs/cloud.svg +15 -0
- package/brands/interim/assets/glyphs/code.svg +15 -0
- package/brands/interim/assets/glyphs/coins.svg +18 -0
- package/brands/interim/assets/glyphs/compass.svg +16 -0
- package/brands/interim/assets/glyphs/cpu.svg +24 -0
- package/brands/interim/assets/glyphs/database.svg +17 -0
- package/brands/interim/assets/glyphs/eye.svg +16 -0
- package/brands/interim/assets/glyphs/file-check.svg +17 -0
- package/brands/interim/assets/glyphs/filter.svg +15 -0
- package/brands/interim/assets/glyphs/fingerprint.svg +23 -0
- package/brands/interim/assets/glyphs/flag.svg +16 -0
- package/brands/interim/assets/glyphs/gauge.svg +16 -0
- package/brands/interim/assets/glyphs/gavel.svg +19 -0
- package/brands/interim/assets/glyphs/git-branch.svg +18 -0
- package/brands/interim/assets/glyphs/globe.svg +17 -0
- package/brands/interim/assets/glyphs/graduation-cap.svg +17 -0
- package/brands/interim/assets/glyphs/handshake.svg +19 -0
- package/brands/interim/assets/glyphs/hexagon.svg +15 -0
- package/brands/interim/assets/glyphs/index.json +177 -0
- package/brands/interim/assets/glyphs/infinity.svg +15 -0
- package/brands/interim/assets/glyphs/key.svg +17 -0
- package/brands/interim/assets/glyphs/landmark.svg +20 -0
- package/brands/interim/assets/glyphs/layers.svg +17 -0
- package/brands/interim/assets/glyphs/lightbulb.svg +17 -0
- package/brands/interim/assets/glyphs/link.svg +16 -0
- package/brands/interim/assets/glyphs/list-checks.svg +19 -0
- package/brands/interim/assets/glyphs/lock.svg +16 -0
- package/brands/interim/assets/glyphs/map.svg +17 -0
- package/brands/interim/assets/glyphs/megaphone.svg +16 -0
- package/brands/interim/assets/glyphs/message-square.svg +15 -0
- package/brands/interim/assets/glyphs/milestone.svg +17 -0
- package/brands/interim/assets/glyphs/network.svg +19 -0
- package/brands/interim/assets/glyphs/orbit.svg +19 -0
- package/brands/interim/assets/glyphs/package.svg +18 -0
- package/brands/interim/assets/glyphs/plug.svg +18 -0
- package/brands/interim/assets/glyphs/puzzle.svg +15 -0
- package/brands/interim/assets/glyphs/radar.svg +22 -0
- package/brands/interim/assets/glyphs/receipt.svg +17 -0
- package/brands/interim/assets/glyphs/refresh-cw.svg +18 -0
- package/brands/interim/assets/glyphs/rocket.svg +18 -0
- package/brands/interim/assets/glyphs/route.svg +17 -0
- package/brands/interim/assets/glyphs/scale.svg +19 -0
- package/brands/interim/assets/glyphs/search.svg +16 -0
- package/brands/interim/assets/glyphs/server.svg +18 -0
- package/brands/interim/assets/glyphs/settings.svg +16 -0
- package/brands/interim/assets/glyphs/share-2.svg +19 -0
- package/brands/interim/assets/glyphs/shield-check.svg +16 -0
- package/brands/interim/assets/glyphs/shuffle.svg +19 -0
- package/brands/interim/assets/glyphs/sparkles.svg +19 -0
- package/brands/interim/assets/glyphs/square-stack.svg +17 -0
- package/brands/interim/assets/glyphs/target.svg +17 -0
- package/brands/interim/assets/glyphs/trending-up.svg +16 -0
- package/brands/interim/assets/glyphs/triangle-alert.svg +17 -0
- package/brands/interim/assets/glyphs/trophy.svg +20 -0
- package/brands/interim/assets/glyphs/users.svg +18 -0
- package/brands/interim/assets/glyphs/wallet.svg +16 -0
- package/brands/interim/assets/glyphs/wand-sparkles.svg +22 -0
- package/brands/interim/assets/glyphs/workflow.svg +17 -0
- package/brands/interim/assets/glyphs/zap.svg +15 -0
- package/brands/interim/assets/mark.svg +1 -0
- package/brands/interim/assets/social/composition.json +6833 -0
- package/brands/interim/assets/spark-lavender.png +0 -0
- package/brands/interim/assets/spark-violet.png +0 -0
- package/brands/interim/assets/statement-art.png +0 -0
- package/brands/interim/assets/story/agent-alpha.png +0 -0
- package/brands/interim/assets/story/agent.png +0 -0
- package/brands/interim/assets/story/economy-alpha.png +0 -0
- package/brands/interim/assets/story/economy.png +0 -0
- package/brands/interim/assets/story/index.json +214 -0
- package/brands/interim/assets/story/learning-alpha.png +0 -0
- package/brands/interim/assets/story/learning.png +0 -0
- package/brands/interim/assets/story/organization-alpha.png +0 -0
- package/brands/interim/assets/story/organization.png +0 -0
- package/brands/interim/assets/story/worker-alpha.png +0 -0
- package/brands/interim/assets/story/worker-light.png +0 -0
- package/brands/interim/assets/story/worker.png +0 -0
- package/brands/interim/assets/wordmark-on-dark.png +0 -0
- package/brands/interim/assets/wordmark-on-dark.svg +1 -0
- package/brands/interim/assets/wordmark-on-light.png +0 -0
- package/brands/interim/assets/wordmark-on-light.svg +1 -0
- package/brands/interim/fonts/Geist-Book.ttf +0 -0
- package/brands/interim/fonts/Geist-Medium.ttf +0 -0
- package/brands/interim/fonts/Geist-Regular.ttf +0 -0
- package/brands/interim/fonts/Geist-SemiBold.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Italic.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Medium.ttf +0 -0
- package/brands/interim/fonts/Newsreader-MediumItalic.ttf +0 -0
- package/brands/interim/fonts/Newsreader-Regular.ttf +0 -0
- package/brands/interim/fonts/OFL-Geist.txt +93 -0
- package/brands/interim/fonts/OFL-Newsreader.txt +93 -0
- package/brands/interim/fonts/faces.json +12 -0
- package/brands/interim/lib/composition.js +169 -0
- package/brands/interim/lib/composition.mjs +101 -0
- package/brands/interim/lib/glyphs.js +89 -0
- package/brands/interim/lib/measure.js +121 -0
- package/brands/interim/lib/pdf.js +597 -0
- package/brands/interim/lib/ttf.py +169 -0
- package/brands/interim/lib/wandxml.py +37 -0
- package/brands/interim/tokens.json +299 -0
- package/examples/social/social.json +23 -0
- package/lib/audit.js +142 -0
- package/lib/brand.js +12 -7
- package/lib/components.js +13 -8
- package/lib/engine.js +55 -9
- package/lib/library.js +180 -0
- package/lib/pptx.js +1 -1
- package/lib/social.js +366 -0
- package/library/index.json +629 -0
- package/library/interim/avatar-800x800.png +0 -0
- package/library/interim/icons/apple-touch-icon.png +0 -0
- package/library/interim/icons/favicon-16.png +0 -0
- package/library/interim/icons/favicon-32.png +0 -0
- package/library/interim/icons/favicon-48.png +0 -0
- package/library/interim/icons/favicon.ico +0 -0
- package/library/interim/icons/favicon.svg +1 -0
- package/library/interim/icons/icon-192.png +0 -0
- package/library/interim/icons/icon-512.png +0 -0
- package/library/interim/icons/icon-tags.html +5 -0
- package/library/interim/icons/site.webmanifest +19 -0
- package/library/interim/linkedin-banner-1584x396.png +0 -0
- package/library/interim/linkedin-company-1128x191.png +0 -0
- package/library/interim/open-agent-discovery-share-1200x630.png +0 -0
- package/library/interim/open-agent-discovery-share-head-tags.html +17 -0
- package/library/interim/wand-ai-share-1200x630.png +0 -0
- package/library/interim/wand-ai-share-head-tags.html +17 -0
- package/library/interim/x-header-1500x500.png +0 -0
- package/package.json +2 -2
- package/templates/card.mjs +6 -2
- package/templates/page.mjs +1 -1
- package/templates/slide.mjs +1 -1
- package/templates/storyboard.mjs +1 -1
- package/wand.js +64 -5
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ttf.py — the ~1% of TrueType that embed_fonts.py needs, with no fontTools.
|
|
3
|
+
|
|
4
|
+
Reads `head` and `OS/2` for the fields an EOT header carries, and rewrites the
|
|
5
|
+
`name` table so a face reports the family we address it by. Weight in this
|
|
6
|
+
system is carried by the family name — `Geist SemiBold` regular, not `Geist`
|
|
7
|
+
bold — so retagging is not cosmetic: without it PowerPoint resolves four of the
|
|
8
|
+
five families to the same face.
|
|
9
|
+
|
|
10
|
+
Everything else in the font is copied through untouched.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import struct
|
|
14
|
+
|
|
15
|
+
_HEAD_CHECKSUM_ADJ = 8 # offset of checkSumAdjustment within `head`
|
|
16
|
+
_MAGIC = 0xB1B0AFBA
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TTFError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TTF:
|
|
24
|
+
def __init__(self, data):
|
|
25
|
+
if len(data) < 12:
|
|
26
|
+
raise TTFError("not a font: too short")
|
|
27
|
+
tag = data[:4]
|
|
28
|
+
if tag not in (b"\x00\x01\x00\x00", b"true", b"ttcf", b"OTTO"):
|
|
29
|
+
raise TTFError(f"unrecognised sfnt tag {tag!r}")
|
|
30
|
+
if tag == b"ttcf":
|
|
31
|
+
raise TTFError("font collections (.ttc) are not supported — pass a single face")
|
|
32
|
+
self.data = data
|
|
33
|
+
self.sfnt = tag
|
|
34
|
+
(num,) = struct.unpack(">H", data[4:6])
|
|
35
|
+
self.tables = {}
|
|
36
|
+
for i in range(num):
|
|
37
|
+
off = 12 + i * 16
|
|
38
|
+
t, csum, o, ln = struct.unpack(">4sIII", data[off:off + 16])
|
|
39
|
+
self.tables[t] = (o, ln, csum)
|
|
40
|
+
|
|
41
|
+
# -- reading -----------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def table(self, tag):
|
|
44
|
+
if isinstance(tag, str):
|
|
45
|
+
tag = tag.encode()
|
|
46
|
+
if tag not in self.tables:
|
|
47
|
+
raise TTFError(f"font has no {tag.decode()} table")
|
|
48
|
+
o, ln, _ = self.tables[tag]
|
|
49
|
+
return self.data[o:o + ln]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def check_sum_adjustment(self):
|
|
53
|
+
return struct.unpack(">I", self.table("head")[8:12])[0]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def os2(self):
|
|
57
|
+
"""The OS/2 fields the EOT header needs."""
|
|
58
|
+
t = self.table("OS/2")
|
|
59
|
+
version = struct.unpack(">H", t[0:2])[0]
|
|
60
|
+
d = {
|
|
61
|
+
"version": version,
|
|
62
|
+
"usWeightClass": struct.unpack(">H", t[4:6])[0],
|
|
63
|
+
"panose": t[32:42],
|
|
64
|
+
"ulUnicodeRange": struct.unpack(">IIII", t[42:58]),
|
|
65
|
+
"fsSelection": struct.unpack(">H", t[62:64])[0],
|
|
66
|
+
# the font's own embedding permission; the EOT header must not claim more
|
|
67
|
+
"fsType": struct.unpack(">H", t[8:10])[0],
|
|
68
|
+
}
|
|
69
|
+
# code page ranges only exist from version 1
|
|
70
|
+
d["ulCodePageRange"] = struct.unpack(">II", t[78:86]) if version >= 1 and len(t) >= 86 else (0, 0)
|
|
71
|
+
return d
|
|
72
|
+
|
|
73
|
+
def names(self):
|
|
74
|
+
"""{nameID: string} for the Windows/Unicode records, best effort."""
|
|
75
|
+
t = self.table("name")
|
|
76
|
+
count, storage = struct.unpack(">HH", t[2:6])
|
|
77
|
+
out = {}
|
|
78
|
+
for i in range(count):
|
|
79
|
+
p = 6 + i * 12
|
|
80
|
+
plat, enc, lang, nid, ln, off = struct.unpack(">HHHHHH", t[p:p + 12])
|
|
81
|
+
raw = t[storage + off: storage + off + ln]
|
|
82
|
+
try:
|
|
83
|
+
s = raw.decode("utf-16-be") if plat == 3 or (plat == 0) else raw.decode("latin-1")
|
|
84
|
+
except UnicodeDecodeError:
|
|
85
|
+
continue
|
|
86
|
+
out.setdefault(nid, s)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
# -- writing -----------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def retagged(self, family, subfamily, full, postscript):
|
|
92
|
+
"""Return new font bytes with name IDs 1/2/4/6 replaced."""
|
|
93
|
+
replace = {1: family, 2: subfamily, 4: full, 6: postscript}
|
|
94
|
+
name = _rebuild_name(self.table("name"), replace)
|
|
95
|
+
return _reassemble(self, {b"name": name})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _rebuild_name(t, replace):
|
|
99
|
+
count, storage = struct.unpack(">HH", t[2:6])
|
|
100
|
+
records, strings, cursor = [], bytearray(), 0
|
|
101
|
+
|
|
102
|
+
for i in range(count):
|
|
103
|
+
p = 6 + i * 12
|
|
104
|
+
plat, enc, lang, nid, ln, off = struct.unpack(">HHHHHH", t[p:p + 12])
|
|
105
|
+
if nid in replace:
|
|
106
|
+
s = replace[nid]
|
|
107
|
+
raw = s.encode("utf-16-be") if plat in (0, 3) else s.encode("latin-1", "replace")
|
|
108
|
+
else:
|
|
109
|
+
raw = t[storage + off: storage + off + ln]
|
|
110
|
+
records.append((plat, enc, lang, nid, len(raw), cursor))
|
|
111
|
+
strings += raw
|
|
112
|
+
cursor += len(raw)
|
|
113
|
+
|
|
114
|
+
# sort order is required by the spec: platform, encoding, language, name
|
|
115
|
+
records.sort(key=lambda r: (r[0], r[1], r[2], r[3]))
|
|
116
|
+
head = struct.pack(">HHH", 0, len(records), 6 + len(records) * 12)
|
|
117
|
+
body = b"".join(struct.pack(">HHHHHH", *r) for r in records)
|
|
118
|
+
return head + body + bytes(strings)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _checksum(b):
|
|
122
|
+
if len(b) % 4:
|
|
123
|
+
b = b + b"\0" * (4 - len(b) % 4)
|
|
124
|
+
return sum(struct.unpack(f">{len(b)//4}I", b)) & 0xFFFFFFFF
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _reassemble(font, overrides):
|
|
128
|
+
"""Rewrite the whole sfnt with some tables replaced, fixing every offset,
|
|
129
|
+
every table checksum and the head checkSumAdjustment."""
|
|
130
|
+
tags = sorted(font.tables)
|
|
131
|
+
parts = {}
|
|
132
|
+
for t in tags:
|
|
133
|
+
parts[t] = overrides.get(t, font.table(t))
|
|
134
|
+
|
|
135
|
+
# head's checkSumAdjustment must be zero while the file checksum is taken
|
|
136
|
+
head = bytearray(parts[b"head"])
|
|
137
|
+
head[_HEAD_CHECKSUM_ADJ:_HEAD_CHECKSUM_ADJ + 4] = b"\0\0\0\0"
|
|
138
|
+
parts[b"head"] = bytes(head)
|
|
139
|
+
|
|
140
|
+
n = len(tags)
|
|
141
|
+
# binary-search fields the spec wants present; readers ignore them but
|
|
142
|
+
# validators complain when they are wrong
|
|
143
|
+
entry_selector = max(n.bit_length() - 1, 0)
|
|
144
|
+
search_range = (1 << entry_selector) * 16
|
|
145
|
+
out = bytearray(struct.pack(">4sHHHH", font.sfnt, n, search_range,
|
|
146
|
+
entry_selector, n * 16 - search_range))
|
|
147
|
+
|
|
148
|
+
offset = 12 + n * 16
|
|
149
|
+
directory, body = bytearray(), bytearray()
|
|
150
|
+
for t in tags:
|
|
151
|
+
d = parts[t]
|
|
152
|
+
directory += struct.pack(">4sIII", t, _checksum(d), offset, len(d))
|
|
153
|
+
body += d
|
|
154
|
+
pad = (-len(d)) % 4
|
|
155
|
+
body += b"\0" * pad
|
|
156
|
+
offset += len(d) + pad
|
|
157
|
+
|
|
158
|
+
out += directory + body
|
|
159
|
+
adj = (_MAGIC - _checksum(bytes(out))) & 0xFFFFFFFF
|
|
160
|
+
|
|
161
|
+
head_off = struct.unpack(">I", out[12 + tags.index(b"head") * 16 + 8:
|
|
162
|
+
12 + tags.index(b"head") * 16 + 12])[0]
|
|
163
|
+
out[head_off + _HEAD_CHECKSUM_ADJ: head_off + _HEAD_CHECKSUM_ADJ + 4] = struct.pack(">I", adj)
|
|
164
|
+
return bytes(out)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def load(path):
|
|
168
|
+
with open(path, "rb") as f:
|
|
169
|
+
return TTF(f.read())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
wandxml.py — XML parsing that works with nothing installed.
|
|
3
|
+
|
|
4
|
+
The kit has to run wherever someone unzips it. `defusedxml` is the better
|
|
5
|
+
parser and we use it when it's there, but requiring a pip install to get past
|
|
6
|
+
Phase 0 means the engine is unusable in exactly the situation it was built for:
|
|
7
|
+
a fresh container with a zip and a .pptx in it.
|
|
8
|
+
|
|
9
|
+
The stdlib fallback is guarded against the one attack that matters for a parser
|
|
10
|
+
pointed at an untrusted .pptx — entity expansion — by refusing any document with
|
|
11
|
+
a DOCTYPE. Office files never have one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from defusedxml import minidom as _md
|
|
16
|
+
HARDENED = True
|
|
17
|
+
except ImportError: # stdlib fallback
|
|
18
|
+
from xml.dom import minidom as _md
|
|
19
|
+
HARDENED = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnsafeXML(ValueError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse(data):
|
|
27
|
+
"""Parse .pptx part bytes (or str) into a minidom Document."""
|
|
28
|
+
probe = data[:8192]
|
|
29
|
+
if isinstance(probe, bytes):
|
|
30
|
+
probe = probe.decode("utf-8", "ignore")
|
|
31
|
+
if not HARDENED and ("<!DOCTYPE" in probe or "<!ENTITY" in probe):
|
|
32
|
+
raise UnsafeXML(
|
|
33
|
+
"document declares a DOCTYPE or ENTITY. Office parts never do. "
|
|
34
|
+
"Refusing to parse it with the stdlib parser — `pip install defusedxml` "
|
|
35
|
+
"if this file is genuinely trusted."
|
|
36
|
+
)
|
|
37
|
+
return _md.parseString(data)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "Canonical brand values of the interim wand.ai look, in the shape the studio kit reads (swatch, derived, roles, faces, shape, art). Two sources: the live site (build oval-mobile-20261020-r1, read 2026-10-21: its CSS custom properties, the hero's constants, the marks' colours) and the social design approved on this brand on 2026-10-22 (X header, LinkedIn banners, avatar), which wins where they differ: the dark ground, ink on dark, the display and phrase weights, the spark, the avatar's tile. BRAND.md documents usage; the `from` map says where each value sits.",
|
|
3
|
+
"name": "interim",
|
|
4
|
+
"title": "Wand, the interim website's brand (Geist, Newsreader)",
|
|
5
|
+
"source": {
|
|
6
|
+
"site": "https://wand.ai/",
|
|
7
|
+
"build": "oval-mobile-20261020-r1",
|
|
8
|
+
"read": "2026-10-21",
|
|
9
|
+
"files": [
|
|
10
|
+
"site.20261020-r1.css",
|
|
11
|
+
"scale-story.20261020-r1.css",
|
|
12
|
+
"wand-refinement-20261020.20261020-r1.css",
|
|
13
|
+
"chapter-navigation.20261020-r1.css",
|
|
14
|
+
"assets/wand-icon-dark-v4.svg",
|
|
15
|
+
"assets/wand-wordmark-light-violet.svg",
|
|
16
|
+
"scale-story.desktop-wheel-20261020-r4.js"
|
|
17
|
+
],
|
|
18
|
+
"approved": {
|
|
19
|
+
"date": "2026-10-22",
|
|
20
|
+
"files": [
|
|
21
|
+
"X header 1500x500",
|
|
22
|
+
"LinkedIn banner 1584x396",
|
|
23
|
+
"LinkedIn company banner 1512x256",
|
|
24
|
+
"avatar 800",
|
|
25
|
+
"LinkedIn profile mock-up"
|
|
26
|
+
],
|
|
27
|
+
"kept": "runs/2026-10-22-wand-og/ref",
|
|
28
|
+
"measured": "assets/social/composition.json",
|
|
29
|
+
"precedence": "the approved design wins over the site where they differ"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"swatch": {
|
|
33
|
+
"night": "0B0913",
|
|
34
|
+
"ground": "14101C",
|
|
35
|
+
"nightDeep": "08060D",
|
|
36
|
+
"nightSoft": "100C19",
|
|
37
|
+
"plum": "2D1D40",
|
|
38
|
+
"ink": "221B2D",
|
|
39
|
+
"paper": "FDFCF9",
|
|
40
|
+
"stone": "F5F3EF",
|
|
41
|
+
"lilac": "F2EAFA",
|
|
42
|
+
"lavender": "D6BFF5",
|
|
43
|
+
"violet": "C2A1EF",
|
|
44
|
+
"violetStrong": "6D41B3",
|
|
45
|
+
"phraseLight": "765098",
|
|
46
|
+
"eyebrowLight": "836494",
|
|
47
|
+
"muted": "6E6577",
|
|
48
|
+
"mutedLight": "B6ABCA",
|
|
49
|
+
"storyInk": "F4EEF8",
|
|
50
|
+
"storyText": "C1B5CD",
|
|
51
|
+
"storyMuted": "B5A6C5",
|
|
52
|
+
"storyDetail": "9C8DAB",
|
|
53
|
+
"emphasis": "D4BCE8",
|
|
54
|
+
"kicker": "C7B7D6",
|
|
55
|
+
"lineLight": "D8CEDF",
|
|
56
|
+
"spark": "C2A1EF",
|
|
57
|
+
"sparkOnLight": "765098",
|
|
58
|
+
"wordmarkOnLight": "240647",
|
|
59
|
+
"tile": "261D3D",
|
|
60
|
+
"white": "FFFFFF",
|
|
61
|
+
"inkDark": "F7F5F9"
|
|
62
|
+
},
|
|
63
|
+
"from": {
|
|
64
|
+
"night": "--night: the site's html ground, its hero and <meta theme-color>; no longer the brand's dark ground (see ground)",
|
|
65
|
+
"nightDeep": "--night-deep: the footer",
|
|
66
|
+
"nightSoft": "the mobile navigation, the product menu, .contact-next",
|
|
67
|
+
"plum": ".button-dark",
|
|
68
|
+
"ink": "--ink: body colour on light",
|
|
69
|
+
"paper": "--paper",
|
|
70
|
+
"stone": "--stone: the body ground",
|
|
71
|
+
"lilac": ".nav-cta ground; the editorial cards run #e9e0f1 to #f6f0fa",
|
|
72
|
+
"lavender": "--lavender",
|
|
73
|
+
"violet": "--violet: h1 em, h2 em on dark; the current nav item's underline",
|
|
74
|
+
"violetStrong": "--violet-strong",
|
|
75
|
+
"phraseLight": ".light-section h1 em, h2 em",
|
|
76
|
+
"eyebrowLight": ".eyebrow on light (chapter-navigation); 4.5:1 on stone by a hair under AA, so the kit's small muted text takes phraseLight",
|
|
77
|
+
"muted": "--muted: body text on light",
|
|
78
|
+
"mutedLight": "--muted-light",
|
|
79
|
+
"storyInk": ".scale-story --story-ink: the hero's text",
|
|
80
|
+
"storyText": ".scale-story-caption > p",
|
|
81
|
+
"storyMuted": ".scale-story --story-muted",
|
|
82
|
+
"storyDetail": ".scale-story-detail",
|
|
83
|
+
"emphasis": ".scale-story-title em: the hero's italic phrase",
|
|
84
|
+
"kicker": ".scale-story-kicker",
|
|
85
|
+
"lineLight": "the light sections' hairline (#d8cedf)",
|
|
86
|
+
"spark": "the approved wordmark and avatar: the spark is the phrase's violet (the site's spark was B6A2FF)",
|
|
87
|
+
"sparkOnLight": "the approved rule carried to the light ground: the spark is the phrase's colour (765098 on light; the site's was 6D4CF5)",
|
|
88
|
+
"wordmarkOnLight": "the letters in wand-wordmark-dark.svg",
|
|
89
|
+
"tile": "the approved avatar's disc (the site's favicon tile was 280845)",
|
|
90
|
+
"ground": "the approved social design (2026-10-22), flat on every export: the dark ground, in place of the site's night",
|
|
91
|
+
"inkDark": "the approved design's 'A new workforce.': ink on dark, in place of the site's story ink"
|
|
92
|
+
},
|
|
93
|
+
"derived": {
|
|
94
|
+
"hairlineDark": {
|
|
95
|
+
"hex": "322F39",
|
|
96
|
+
"from": "white @ 13% on the ground: --dark-line #ffffff21"
|
|
97
|
+
},
|
|
98
|
+
"strokeDark": {
|
|
99
|
+
"hex": "26232E",
|
|
100
|
+
"from": "white @ 8% on the ground: the card borders #ffffff14"
|
|
101
|
+
},
|
|
102
|
+
"surfaceDark": {
|
|
103
|
+
"hex": "21192E",
|
|
104
|
+
"from": "the site's dark card ground (#21192e)"
|
|
105
|
+
},
|
|
106
|
+
"paperDark": {
|
|
107
|
+
"hex": "191322",
|
|
108
|
+
"from": "cards on dark: the ground lifted as the site lifts nightSoft off night (+5, +3, +6)"
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"glow": {
|
|
112
|
+
"_": "the hero's ambient light: the site's two radial gradients as hex + alpha, and its hairlines; a layout positions them",
|
|
113
|
+
"a": {
|
|
114
|
+
"hex": "B183EB",
|
|
115
|
+
"alpha": 0.09,
|
|
116
|
+
"from": "radial-gradient(ellipse at 75% 24%, #b183eb17, transparent 60%)"
|
|
117
|
+
},
|
|
118
|
+
"b": {
|
|
119
|
+
"hex": "654393",
|
|
120
|
+
"alpha": 0.094,
|
|
121
|
+
"from": "radial-gradient(ellipse at 88% 25%, #65439318, transparent 52%)"
|
|
122
|
+
},
|
|
123
|
+
"hairline": {
|
|
124
|
+
"hex": "FFFFFF",
|
|
125
|
+
"alpha": 0.13,
|
|
126
|
+
"from": "--dark-line: #ffffff21"
|
|
127
|
+
},
|
|
128
|
+
"kickerDot": {
|
|
129
|
+
"hex": "D8BBEE",
|
|
130
|
+
"halo": "CDA4EA",
|
|
131
|
+
"from": ".scale-story-kicker > span"
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"roles": {
|
|
135
|
+
"light": {
|
|
136
|
+
"bg": "F5F3EF",
|
|
137
|
+
"paper": "FDFCF9",
|
|
138
|
+
"ink": "221B2D",
|
|
139
|
+
"text": "6E6577",
|
|
140
|
+
"soft": "6E6577",
|
|
141
|
+
"muted": "765098",
|
|
142
|
+
"accent": "6D41B3",
|
|
143
|
+
"accentText": "6D41B3",
|
|
144
|
+
"label": "765098",
|
|
145
|
+
"emph": "14101C",
|
|
146
|
+
"emphInk": "F4EEF8",
|
|
147
|
+
"emphText": "C1B5CD",
|
|
148
|
+
"emphAccent": "C2A1EF",
|
|
149
|
+
"stroke": "D8CEDF",
|
|
150
|
+
"rule": "D8CEDF",
|
|
151
|
+
"track": "D8CEDF",
|
|
152
|
+
"surface": "F2EAFA",
|
|
153
|
+
"emphLabel": "C7B7D6",
|
|
154
|
+
"second": "765098",
|
|
155
|
+
"secondSm": "765098",
|
|
156
|
+
"gradeLow": "14101C",
|
|
157
|
+
"gradeHigh": "F5F3EF"
|
|
158
|
+
},
|
|
159
|
+
"dark": {
|
|
160
|
+
"bg": "14101C",
|
|
161
|
+
"paper": "191322",
|
|
162
|
+
"ink": "F7F5F9",
|
|
163
|
+
"text": "C1B5CD",
|
|
164
|
+
"soft": "B5A6C5",
|
|
165
|
+
"muted": "9C8DAB",
|
|
166
|
+
"accent": "D4BCE8",
|
|
167
|
+
"accentText": "C2A1EF",
|
|
168
|
+
"label": "C7B7D6",
|
|
169
|
+
"emph": "2D1D40",
|
|
170
|
+
"emphInk": "F9F4FF",
|
|
171
|
+
"emphText": "C1B5CD",
|
|
172
|
+
"emphAccent": "C2A1EF",
|
|
173
|
+
"stroke": "26232E",
|
|
174
|
+
"rule": "322F39",
|
|
175
|
+
"track": "322F39",
|
|
176
|
+
"surface": "21192E",
|
|
177
|
+
"emphLabel": "C7B7D6",
|
|
178
|
+
"second": "C2A1EF",
|
|
179
|
+
"secondSm": "B5A6C5",
|
|
180
|
+
"gradeLow": "14101C",
|
|
181
|
+
"gradeHigh": "F5F3EF"
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
"faces": {
|
|
185
|
+
"display": {
|
|
186
|
+
"family": "Geist",
|
|
187
|
+
"weight": 500,
|
|
188
|
+
"tracking": 0,
|
|
189
|
+
"leading": 1.025,
|
|
190
|
+
"_": "approved 2026-10-22: Geist Medium, untracked (stems 17.6 units at 140, as exported). The site's hero title was Geist 450 at -0.063em; its leading, 1.025, stays, since the approved set shows no wrapped title (its lead and phrase sit 1.11 apart, a lockup the composition carries)",
|
|
191
|
+
"coverTracking": 0
|
|
192
|
+
},
|
|
193
|
+
"body": {
|
|
194
|
+
"family": "Geist",
|
|
195
|
+
"weight": 400,
|
|
196
|
+
"tracking": 0,
|
|
197
|
+
"leading": 1.6
|
|
198
|
+
},
|
|
199
|
+
"label": {
|
|
200
|
+
"family": "Geist",
|
|
201
|
+
"weight": 500,
|
|
202
|
+
"tracking": 0.11,
|
|
203
|
+
"leading": 1.5,
|
|
204
|
+
"caps": true
|
|
205
|
+
},
|
|
206
|
+
"phrase": {
|
|
207
|
+
"family": "Newsreader",
|
|
208
|
+
"weight": 500,
|
|
209
|
+
"italic": false,
|
|
210
|
+
"tracking": 0,
|
|
211
|
+
"artItalic": true,
|
|
212
|
+
"_": "approved 2026-10-22: Newsreader Medium, optical size 72, untracked, the italic on the pages that carry the art (the hero's phrase); roman elsewhere, as the site's h1 em. The site set it at 400, -0.049em"
|
|
213
|
+
},
|
|
214
|
+
"files": [
|
|
215
|
+
{
|
|
216
|
+
"family": "Geist",
|
|
217
|
+
"weight": 400,
|
|
218
|
+
"italic": false,
|
|
219
|
+
"file": "Geist-Regular.ttf",
|
|
220
|
+
"pptx": "Geist"
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"family": "Geist",
|
|
224
|
+
"weight": 450,
|
|
225
|
+
"italic": false,
|
|
226
|
+
"file": "Geist-Book.ttf",
|
|
227
|
+
"pptx": "Geist Book"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"family": "Geist",
|
|
231
|
+
"weight": 500,
|
|
232
|
+
"italic": false,
|
|
233
|
+
"file": "Geist-Medium.ttf",
|
|
234
|
+
"pptx": "Geist Medium"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"family": "Geist",
|
|
238
|
+
"weight": 600,
|
|
239
|
+
"italic": false,
|
|
240
|
+
"file": "Geist-SemiBold.ttf",
|
|
241
|
+
"pptx": "Geist SemiBold"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
"family": "Newsreader",
|
|
245
|
+
"weight": 400,
|
|
246
|
+
"italic": false,
|
|
247
|
+
"file": "Newsreader-Regular.ttf",
|
|
248
|
+
"pptx": "Newsreader"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"family": "Newsreader",
|
|
252
|
+
"weight": 400,
|
|
253
|
+
"italic": true,
|
|
254
|
+
"file": "Newsreader-Italic.ttf",
|
|
255
|
+
"pptx": "Newsreader"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
"family": "Newsreader",
|
|
259
|
+
"weight": 500,
|
|
260
|
+
"italic": false,
|
|
261
|
+
"file": "Newsreader-Medium.ttf",
|
|
262
|
+
"pptx": "Newsreader Medium"
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"family": "Newsreader",
|
|
266
|
+
"weight": 500,
|
|
267
|
+
"italic": true,
|
|
268
|
+
"file": "Newsreader-MediumItalic.ttf",
|
|
269
|
+
"pptx": "Newsreader Medium"
|
|
270
|
+
}
|
|
271
|
+
],
|
|
272
|
+
"license": "Geist and Newsreader: SIL OFL 1.1, static instances of the Google Fonts variable sources (see fonts/LICENSE-FONTS.md)"
|
|
273
|
+
},
|
|
274
|
+
"shape": {
|
|
275
|
+
"radius": 1,
|
|
276
|
+
"cardStroke": 1,
|
|
277
|
+
"emphStroke": 0,
|
|
278
|
+
"artShade": 0,
|
|
279
|
+
"_": "artShade: the dark art pages carry no shade over their art, as the approved composition carries none over its trails and ring; the statement and closing art bake in the 30% the kit used to lay over them (tools/art.mjs)"
|
|
280
|
+
},
|
|
281
|
+
"art": {
|
|
282
|
+
"cover": "cover-art.png",
|
|
283
|
+
"coverPortrait": "cover-portrait-art.png",
|
|
284
|
+
"overlay": "cover-art.png",
|
|
285
|
+
"statement": "statement-art.png",
|
|
286
|
+
"closing": "closing-art.png",
|
|
287
|
+
"coverArt": "cover-art.png",
|
|
288
|
+
"divider": "divider-art.png",
|
|
289
|
+
"markOnLight": "spark-violet.png",
|
|
290
|
+
"markOnDark": "spark-lavender.png",
|
|
291
|
+
"wordmarkOnLight": "wordmark-on-light.png",
|
|
292
|
+
"wordmarkOnDark": "wordmark-on-dark.png",
|
|
293
|
+
"wordmarkSvgOnLight": "wordmark-on-light.svg",
|
|
294
|
+
"wordmarkSvgOnDark": "wordmark-on-dark.svg",
|
|
295
|
+
"mark": "mark.svg",
|
|
296
|
+
"avatar": "avatar.svg",
|
|
297
|
+
"composition": "social/composition.json"
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wand-social",
|
|
3
|
+
"brand": "interim",
|
|
4
|
+
"site": "Wand",
|
|
5
|
+
"line": { "lead": "A new workforce.", "phrase": "A new operating era." },
|
|
6
|
+
"upload": "https://wand.ai/assets/og/20261023/",
|
|
7
|
+
"cards": [
|
|
8
|
+
{ "kind": "linkedin-banner" },
|
|
9
|
+
{ "kind": "x-header" },
|
|
10
|
+
{ "kind": "linkedin-company" },
|
|
11
|
+
{ "kind": "share", "id": "wand-ai-share", "url": "https://wand.ai/", "title": "Wand AI", "type": "website",
|
|
12
|
+
"description": "Autonomous AI workers. One operating system for people and agents. The foundation for a new era of enterprise and national capacity.",
|
|
13
|
+
"alt": "Wand. A new workforce. A new operating era." },
|
|
14
|
+
{ "kind": "share", "id": "open-agent-discovery-share", "url": "https://wand.ai/insights/open-agent-discovery-nandaindex.html",
|
|
15
|
+
"lead": "An Open Agentic Web", "phrase": "Needs Open Discovery", "type": "article",
|
|
16
|
+
"title": "An Open Agentic Web Needs Open Discovery | Wand AI",
|
|
17
|
+
"description": "Why interoperable agent discovery matters for the emerging agentic economy, and how NandaIndex complements governed AI workforces.",
|
|
18
|
+
"alt": "Wand. An Open Agentic Web Needs Open Discovery." },
|
|
19
|
+
{ "kind": "square" },
|
|
20
|
+
{ "kind": "avatar" },
|
|
21
|
+
{ "kind": "icons", "upload": "/assets/20261022/" }
|
|
22
|
+
]
|
|
23
|
+
}
|
package/lib/audit.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/audit.js — how a page's link preview looks today, before anyone redraws it.
|
|
3
|
+
*
|
|
4
|
+
* The 2026-10-23 fix (runs/2026-10-23-wand-og-fix) began by reading the live
|
|
5
|
+
* pages: one image for every page, its words outside the centre square Slack
|
|
6
|
+
* keeps, small print that vanished in LinkedIn's compact card, an og:title that
|
|
7
|
+
* repeated the image, an em dash, no twitter tags. This reads the same things
|
|
8
|
+
* from any page and shows its image the way those feeds do.
|
|
9
|
+
*
|
|
10
|
+
* node wand.js social --audit https://wand.ai/ https://wand.ai/insights/… [--out audit]
|
|
11
|
+
* node wand.js social --audit saved-page.html --image its-og-image.png
|
|
12
|
+
*
|
|
13
|
+
* A chat with no web access gets a clear row saying so: save the page (and its
|
|
14
|
+
* image) and pass the files.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { browser } from "./engine.js";
|
|
20
|
+
import { fetchTo, feedRows, wholeWithSquare, previewPage } from "./social.js";
|
|
21
|
+
|
|
22
|
+
const LIMIT = 300 * 1024;
|
|
23
|
+
const ENT = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: " ", mdash: "—", ndash: "–", rsquo: "’", lsquo: "‘", rdquo: "”", ldquo: "“", hellip: "…" };
|
|
24
|
+
const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => e[0] === "#" ? String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : +e.slice(1)) : ENT[e.toLowerCase()] ?? m);
|
|
25
|
+
|
|
26
|
+
/** The page's og: and twitter: tags (the first of each, as Slack reads them), how many twitter:card tags, and its <title>. */
|
|
27
|
+
export function readTags(html) {
|
|
28
|
+
const tags = {}, count = {};
|
|
29
|
+
for (const [m] of html.matchAll(/<meta\b[^>]*>/gi)) {
|
|
30
|
+
const attr = {};
|
|
31
|
+
for (const [, k, , a, b] of m.matchAll(/([\w:-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) attr[k.toLowerCase()] = decode(a ?? b ?? "");
|
|
32
|
+
const key = (attr.property || attr.name || "").toLowerCase();
|
|
33
|
+
if (!/^(og|twitter):/.test(key) || attr.content == null) continue;
|
|
34
|
+
count[key] = (count[key] || 0) + 1;
|
|
35
|
+
if (!(key in tags)) tags[key] = attr.content;
|
|
36
|
+
}
|
|
37
|
+
const title = decode((/<title[^>]*>([\s\S]*?)<\/title>/i.exec(html) || [])[1] || "").trim();
|
|
38
|
+
return { tags, count, title };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Width, height and type from a PNG, JPEG, GIF or WebP's own header. */
|
|
42
|
+
export function imageFacts(file) {
|
|
43
|
+
const b = fs.readFileSync(file), bytes = b.length;
|
|
44
|
+
if (b[0] === 0x89 && b.toString("ascii", 1, 4) === "PNG") return { type: "png", w: b.readUInt32BE(16), h: b.readUInt32BE(20), bytes };
|
|
45
|
+
if (b.toString("ascii", 0, 3) === "GIF") return { type: "gif", w: b.readUInt16LE(6), h: b.readUInt16LE(8), bytes };
|
|
46
|
+
if (b.toString("ascii", 8, 12) === "WEBP") {
|
|
47
|
+
const k = b.toString("ascii", 12, 16);
|
|
48
|
+
if (k === "VP8X") return { type: "webp", w: 1 + b.readUIntLE(24, 3), h: 1 + b.readUIntLE(27, 3), bytes };
|
|
49
|
+
if (k === "VP8L") { const v = b.readUInt32LE(21); return { type: "webp", w: (v & 0x3fff) + 1, h: ((v >> 14) & 0x3fff) + 1, bytes }; }
|
|
50
|
+
return { type: "webp", w: b.readUInt16LE(26) & 0x3fff, h: b.readUInt16LE(28) & 0x3fff, bytes };
|
|
51
|
+
}
|
|
52
|
+
if (b[0] === 0xff && b[1] === 0xd8) {
|
|
53
|
+
for (let i = 2; i < b.length - 9;) {
|
|
54
|
+
if (b[i] !== 0xff) { i++; continue; }
|
|
55
|
+
const m = b[i + 1], len = b.readUInt16BE(i + 2);
|
|
56
|
+
if (m >= 0xc0 && m <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(m)) return { type: "jpeg", w: b.readUInt16BE(i + 7), h: b.readUInt16BE(i + 5), bytes };
|
|
57
|
+
i += 2 + len;
|
|
58
|
+
}
|
|
59
|
+
return { type: "jpeg", w: 0, h: 0, bytes };
|
|
60
|
+
}
|
|
61
|
+
return { type: "unknown", w: 0, h: 0, bytes };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
65
|
+
/** A short name for a page: the last part of its path, or its host for a home page. */
|
|
66
|
+
const slug = (t) => {
|
|
67
|
+
const u = /^https?:\/\//.test(t) ? new URL(t) : null, last = (u ? u.pathname : t).split("/").filter(Boolean).pop();
|
|
68
|
+
return norm((last || u?.hostname || "page").replace(/\.[a-z0-9]+$/i, "")).replace(/ /g, "-").slice(0, 40) || "page";
|
|
69
|
+
};
|
|
70
|
+
/** The image's words (its alt, less the brand's name) found again in the og:title: a preview that says them twice. */
|
|
71
|
+
const repeats = (title, alt) => {
|
|
72
|
+
const words = (s) => norm(s).split(" ").filter((w) => w && !["wand", "ai", "a", "an", "the"].includes(w));
|
|
73
|
+
const t = new Set(words(title)), a = words(alt);
|
|
74
|
+
return a.length >= 3 && a.filter((w) => t.has(w)).length / a.length >= 0.8;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export async function audit(targets, { image = null, out = "audit" } = {}) {
|
|
78
|
+
if (!targets.length) throw new Error("usage: node wand.js social --audit <url | page.html> [more] [--image og-image.png] [--out audit]");
|
|
79
|
+
out = path.resolve(out); fs.mkdirSync(out, { recursive: true });
|
|
80
|
+
const rows = [], pages = [];
|
|
81
|
+
const row = (ok, gate, msg, warn = false) => rows.push({ ok, warn, gate, msg });
|
|
82
|
+
for (const t of targets) {
|
|
83
|
+
const id = slug(t), isUrl = /^https?:\/\//.test(t);
|
|
84
|
+
let html;
|
|
85
|
+
try {
|
|
86
|
+
if (isUrl) {
|
|
87
|
+
const r = await fetch(t, { signal: AbortSignal.timeout(15000), headers: { "user-agent": "Mozilla/5.0 (wand-kit link-preview audit)" } });
|
|
88
|
+
if (!r.ok) throw new Error(`answered ${r.status}`);
|
|
89
|
+
html = await r.text();
|
|
90
|
+
} else html = fs.readFileSync(path.resolve(t), "utf8");
|
|
91
|
+
} catch (e) {
|
|
92
|
+
row(false, id, `could not read ${t} from here (${e.cause?.code || e.message}). Save the page (File, Save As) and its og:image, then: --audit page.html --image image.png`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const { tags, count, title } = readTags(html), p = { id, target: t, tags, title };
|
|
96
|
+
pages.push(p);
|
|
97
|
+
const want = ["og:title", "og:description", "og:url", "og:image", "og:image:width", "og:image:height", "og:image:type", "og:image:alt", "twitter:card", "twitter:title", "twitter:image"];
|
|
98
|
+
const missing = want.filter((k) => !tags[k]);
|
|
99
|
+
row(!!tags["og:image"] && !missing.length, `${id} tags`, tags["og:image"] ? (missing.length ? `missing ${missing.join(", ")}` : "every og: and twitter: tag a feed reads") : "no og:image: every feed shows a bare link", !!tags["og:image"] && missing.length > 0);
|
|
100
|
+
if ((count["twitter:card"] || 0) > 1) row(false, `${id} twitter:card`, `${count["twitter:card"]} twitter:card tags; Slack reads the first`, true);
|
|
101
|
+
const dash = Object.entries(tags).filter(([, v]) => /—/.test(v)).map(([k]) => k);
|
|
102
|
+
if (dash.length) row(false, `${id} dashes`, `em dash in ${dash.join(", ")}`, true);
|
|
103
|
+
if (repeats(tags["og:title"], tags["og:image:alt"])) row(false, `${id} title`, "og:title repeats the image's words, so the preview shows them twice", true);
|
|
104
|
+
// the image itself
|
|
105
|
+
const src = image && targets.length === 1 ? image : tags["og:image"] ? new URL(tags["og:image"], isUrl ? t : "https://example.com/").href : null;
|
|
106
|
+
if (tags["og:image"] && !/^https:\/\//.test(tags["og:image"])) row(false, `${id} image url`, `og:image is not an absolute https URL (${tags["og:image"]}); some feeds drop it`, true);
|
|
107
|
+
if (src) {
|
|
108
|
+
try {
|
|
109
|
+
p.png = await fetchTo(src, path.join(out, `live-${id}`), process.cwd());
|
|
110
|
+
const f = imageFacts(p.png); p.facts = f;
|
|
111
|
+
const ratio = f.h ? f.w / f.h : 0, notes = [];
|
|
112
|
+
if (f.bytes > LIMIT) notes.push("over 300 KB: WhatsApp shows no preview");
|
|
113
|
+
if (ratio && Math.abs(ratio / 1.905 - 1) > 0.05) notes.push(`${ratio.toFixed(2)}:1, not 1.91:1: feeds crop it`);
|
|
114
|
+
if (f.w && f.w < 1200) notes.push("under 1200 wide: soft on large cards");
|
|
115
|
+
row(!notes.length, `${id} image`, `${f.w} x ${f.h}, ${Math.round(f.bytes / 1024)} KB, ${f.type}${notes.length ? "; " + notes.join("; ") : ""}`, notes.length > 0);
|
|
116
|
+
} catch (e) { row(false, `${id} image`, `${e.message}`); }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// one picture for every page: nothing in it says what the link is
|
|
120
|
+
const imgs = pages.map((p) => p.tags["og:image"]).filter(Boolean);
|
|
121
|
+
if (pages.length > 1 && new Set(imgs).size < imgs.length) row(false, "pages", `${imgs.length - new Set(imgs).size + 1} pages share one og:image: each needs its own (an article its headline)`, true);
|
|
122
|
+
|
|
123
|
+
const shot = pages.filter((p) => p.png);
|
|
124
|
+
if (shot.length) {
|
|
125
|
+
const u = (f) => pathToFileURL(f).href;
|
|
126
|
+
const body = shot.map((p) => `<h2>${p.target}</h2>${feedRows(u(p.png), { title: p.tags["og:title"] || p.title, url: p.tags["og:url"] || p.target, description: p.tags["og:description"] })}${p.facts?.w ? wholeWithSquare(u(p.png), p.facts.w, p.facts.h) : ""}`).join("\n");
|
|
127
|
+
const pg = previewPage(body), htmlPath = path.join(out, "audit.html"), png = path.join(out, "audit.png");
|
|
128
|
+
fs.writeFileSync(htmlPath, pg.html);
|
|
129
|
+
const b = await browser(), v = await b.newPage({ viewport: { width: pg.w, height: 400 } });
|
|
130
|
+
await v.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
|
|
131
|
+
await v.evaluate(() => Promise.all([...document.images].map((i) => (i.complete ? null : new Promise((r) => { i.onload = i.onerror = r; })))));
|
|
132
|
+
await v.screenshot({ path: png, fullPage: true });
|
|
133
|
+
await v.close();
|
|
134
|
+
row(true, "preview", path.relative(process.cwd(), png));
|
|
135
|
+
}
|
|
136
|
+
const ok = rows.every((r) => r.ok || r.warn);
|
|
137
|
+
console.log("");
|
|
138
|
+
for (const r of rows) console.log(` ${r.ok ? " ok " : r.warn ? " -- " : "FAIL"} ${r.gate.padEnd(34)} ${r.msg}`);
|
|
139
|
+
if (shot.length) console.log(`\n review ${path.relative(process.cwd(), path.join(out, "audit.png"))}, as the people scrolling past will see it:\n does the dashed centre square (Slack's and WhatsApp's cut) hold the whole wordmark and the words?\n in LinkedIn's compact card (128pt), can you read the line? is there small print that vanishes?\n is the type the brand's, and does the image say what this page is?`);
|
|
140
|
+
console.log("");
|
|
141
|
+
return { ok, rows, pages };
|
|
142
|
+
}
|