@justai/cuts 0.157.0 → 0.158.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/package.json +5 -1
- package/src/Catalogue.astro +42 -9
- package/src/CatalogueCard.astro +5 -3
- package/src/behaviour/catalogue.ts +15 -2
- package/src/personaHref.d.ts +6 -0
- package/src/personaHref.js +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@justai/cuts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.158.0",
|
|
4
4
|
"description": "A persona's named parts — the page shell that holds them, and the cuts themselves.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"./Sheet.astro": "./src/Sheet.astro",
|
|
33
33
|
"./Toolbar.astro": "./src/Toolbar.astro",
|
|
34
34
|
"./ground.css": "./src/ground.css",
|
|
35
|
+
"./personaHref": {
|
|
36
|
+
"types": "./src/personaHref.d.ts",
|
|
37
|
+
"default": "./src/personaHref.js"
|
|
38
|
+
},
|
|
35
39
|
"./tagline.js": "./src/tagline.js"
|
|
36
40
|
},
|
|
37
41
|
"peerDependencies": {
|
package/src/Catalogue.astro
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
// statement about what the ecosystem IS, living in one page's markup, twice. It is now a question
|
|
39
39
|
// asked of the registry, which has carried `personaKind` since long before anything read it.
|
|
40
40
|
import CatalogueCard, { type CatalogueEntry } from "./CatalogueCard.astro";
|
|
41
|
+
import { personaHref } from "./personaHref.js";
|
|
41
42
|
|
|
42
43
|
interface Props {
|
|
43
44
|
/** BCP-47 code of the page — the language the registry is asked for. */
|
|
@@ -63,6 +64,19 @@ interface Props {
|
|
|
63
64
|
kind?: string;
|
|
64
65
|
/** The apex's own host, used for the referral parameter on every card. */
|
|
65
66
|
apexHost: string;
|
|
67
|
+
/**
|
|
68
|
+
* queries: localeLinks · clean locale links · ref=app.zone · lang parameter · data-locale-links
|
|
69
|
+
*
|
|
70
|
+
* LINK STRAIGHT TO THE PERSONA'S PAGE IN THIS PAGE'S LANGUAGE — `https://<home>/<lang>/`, no query
|
|
71
|
+
* string — instead of `https://<home>/?ref=<apex>&lang=<lang>`.
|
|
72
|
+
*
|
|
73
|
+
* An apex turns this on when its citizens all serve the apex's languages at `/<lang>/` (app.zone:
|
|
74
|
+
* measured, 37 of 37 personas × 13 languages). It is a statement only the apex can make, which is
|
|
75
|
+
* why it is a prop and not the default: a persona that lacks the language answers 404 at that
|
|
76
|
+
* address, where the old hint was quietly ignored. See ./personaHref.js for the measurements that
|
|
77
|
+
* made the parameters worth removing.
|
|
78
|
+
*/
|
|
79
|
+
localeLinks?: boolean;
|
|
66
80
|
/** Layer one. Empty is valid and correct for an apex with nothing seeded yet. */
|
|
67
81
|
seed?: CatalogueEntry[];
|
|
68
82
|
/** The curated sequence, keyed by host — read by the runtime sort. */
|
|
@@ -102,7 +116,7 @@ interface Props {
|
|
|
102
116
|
};
|
|
103
117
|
}
|
|
104
118
|
const {
|
|
105
|
-
lang, apexHost,
|
|
119
|
+
lang, apexHost, localeLinks = false,
|
|
106
120
|
seed = [], order = [], taglines = {}, catLabels = {}, entries = {},
|
|
107
121
|
locales, strings,
|
|
108
122
|
} = Astro.props;
|
|
@@ -228,8 +242,8 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
|
|
|
228
242
|
{/* taglines ride the same channel as newLabel and catMap: a data attribute on the grid. The
|
|
229
243
|
runtime append builds its cards in plain strings and cannot reach this component's scope, so
|
|
230
244
|
anything it needs has to arrive this way. Keyed by host, which is what the API calls `home`. */}
|
|
231
|
-
<section class="grid" id="grid" data-apex={apexHost} data-new-label={strings.new} data-cat-map={JSON.stringify(catLabels)} data-order={JSON.stringify(order)} data-taglines={JSON.stringify(taglines)}>
|
|
232
|
-
{seed.map((app) => <CatalogueCard app={app} name={appName(app)} desc={appDesc(app)} isNew={isNew(app.added)} newLabel={strings.new} />)}
|
|
245
|
+
<section class="grid" id="grid" data-apex={apexHost} data-locale-links={localeLinks ? "" : undefined} data-new-label={strings.new} data-cat-map={JSON.stringify(catLabels)} data-order={JSON.stringify(order)} data-taglines={JSON.stringify(taglines)}>
|
|
246
|
+
{seed.map((app) => <CatalogueCard app={app} href={localeLinks ? personaHref(hostOf(app.url), lang) : undefined} name={appName(app)} desc={appDesc(app)} isNew={isNew(app.added)} newLabel={strings.new} />)}
|
|
233
247
|
</section>
|
|
234
248
|
{/* queries: runtime cards are unstyled · the card has no CSS · an apex with no seed · scope cid ·
|
|
235
249
|
data-astro-cid · pin.games cards render as blue links
|
|
@@ -264,8 +278,21 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
|
|
|
264
278
|
// locale list has ONE home and is never typed here. A literal array once lived on this line and
|
|
265
279
|
// drifted: it was edited by hand when Italian shipped, and any locale missing from it fell through
|
|
266
280
|
// to "en", which the receiving persona then WROTE as the visitor's saved language choice.
|
|
267
|
-
//
|
|
268
|
-
//
|
|
281
|
+
// queries: ?ref=app.zone&lang= indexed · parameterised persona url in search console · crawler sees
|
|
282
|
+
// ref and lang · Google indexed the hint, not the page · when does a card get ?ref
|
|
283
|
+
//
|
|
284
|
+
// THE VISITOR'S LANGUAGE AND A REFERRAL, HANDED OVER AT THE MOMENT OF THE TAP — never written into
|
|
285
|
+
// the page. This used to rewrite every card's href on load. A crawler renders the script too, so the
|
|
286
|
+
// links it followed were `https://<home>/?ref=<apex>&lang=<xx>`. That address is a page whose HTML is
|
|
287
|
+
// the default language, whose canonical is the root, and whose script goes elsewhere. Search Console
|
|
288
|
+
// then filed those addresses as pages of their own: 232 "Page with redirect" and 67 indexed on
|
|
289
|
+
// app.zone (2026-10-11, see ./personaHref.js).
|
|
290
|
+
//
|
|
291
|
+
// Now the href in the DOM stays whatever the card was rendered with: the persona's own page in this
|
|
292
|
+
// language when the apex opted in (`localeLinks`), its bare root otherwise. The hint is added only
|
|
293
|
+
// when a person actually opens a card: pointerdown, keyboard Enter or a context menu, delegated so
|
|
294
|
+
// runtime-appended cards are covered. A crawler does none of those. An apex on `localeLinks` needs no
|
|
295
|
+
// hint at all, because the address is already the language.
|
|
269
296
|
(function () {
|
|
270
297
|
function detectLang() {
|
|
271
298
|
const saved = localStorage.getItem("lang");
|
|
@@ -277,15 +304,21 @@ const isNew = (added: string): boolean => { const t0 = Date.parse(added || "");
|
|
|
277
304
|
}
|
|
278
305
|
return "en";
|
|
279
306
|
}
|
|
280
|
-
const
|
|
281
|
-
|
|
307
|
+
const grid = document.getElementById("grid");
|
|
308
|
+
if (!grid || grid.dataset.localeLinks !== undefined) return;
|
|
309
|
+
function hint(e) {
|
|
310
|
+
const a = e.target && e.target.closest ? e.target.closest("a.card") : null;
|
|
311
|
+
if (!a || !grid.contains(a)) return;
|
|
282
312
|
try {
|
|
283
313
|
const u = new URL(a.href);
|
|
284
314
|
u.searchParams.set("ref", apexHost);
|
|
285
|
-
u.searchParams.set("lang",
|
|
315
|
+
u.searchParams.set("lang", detectLang());
|
|
286
316
|
a.href = u.toString();
|
|
287
|
-
} catch (
|
|
317
|
+
} catch (err) {}
|
|
288
318
|
}
|
|
319
|
+
// pointerdown precedes click, middle-click and cmd-click alike; keydown covers Enter on a focused
|
|
320
|
+
// card; contextmenu covers "open in new tab". Capture phase, so nothing below can skip it.
|
|
321
|
+
for (const type of ["pointerdown", "keydown", "contextmenu"]) document.addEventListener(type, hint, true);
|
|
289
322
|
})();
|
|
290
323
|
|
|
291
324
|
// queries: can a campaign link to just one app or one category · what is the ?apps= URL parameter ·
|
package/src/CatalogueCard.astro
CHANGED
|
@@ -17,8 +17,10 @@ export interface CatalogueEntry {
|
|
|
17
17
|
// words repeated down the page — "Fitness" four times — carrying almost no information per card, while
|
|
18
18
|
// costing every card a block-level line. As a filter it earns its space instead, and it surfaces the
|
|
19
19
|
// ?category= slice the catalog already supported but never showed. data-category stays: the filter reads it.
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// `href` — where the card goes, when the apex has a better answer than the persona's bare root (its page in
|
|
21
|
+
// this page's language; see Catalogue's `localeLinks`). The icon still comes from `app.url`, the root.
|
|
22
|
+
const { app, name, desc, isNew, newLabel, href } = Astro.props as {
|
|
23
|
+
app: App; name: string; desc: string; isNew: boolean; newLabel: string; href?: string;
|
|
22
24
|
};
|
|
23
25
|
// The persona's canonical icon — every persona serves /icon.svg on its own host. One face everywhere.
|
|
24
26
|
const icon = `${app.url}/icon.svg`;
|
|
@@ -26,7 +28,7 @@ const icon = `${app.url}/icon.svg`;
|
|
|
26
28
|
|
|
27
29
|
<a
|
|
28
30
|
class="card"
|
|
29
|
-
href={app.url}
|
|
31
|
+
href={href ?? app.url}
|
|
30
32
|
data-name={`${name} ${app.name}`.toLowerCase()}
|
|
31
33
|
data-category={app.category}
|
|
32
34
|
data-id={app.id.toLowerCase()}
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// nothing errors, the card just renders completely unstyled. (2) The whole grid is re-sorted after
|
|
22
22
|
// the append, seeds and newcomers together, so ordering is a property of the merged list, not of
|
|
23
23
|
// either source. Then a `zone:cards` event lets the URL filter re-run over the newcomers.
|
|
24
|
+
import { personaHref } from "../personaHref.js";
|
|
25
|
+
|
|
24
26
|
(function () {
|
|
25
27
|
const grid = document.getElementById("grid");
|
|
26
28
|
if (!grid) return;
|
|
@@ -40,6 +42,8 @@
|
|
|
40
42
|
// household where they part — its citizens are sometimes games and sometimes apps — and asking it
|
|
41
43
|
// for one kind returns half a family and calls that the storefront.
|
|
42
44
|
const apexHost = grid.dataset.apex || "app.zone";
|
|
45
|
+
// Present (even empty) when the apex opted in to clean locale links — see Catalogue's `localeLinks`.
|
|
46
|
+
const localeLinks = grid.dataset.localeLinks !== undefined;
|
|
43
47
|
// The category label must be localized like the SSR path (catLabel), but the API returns the
|
|
44
48
|
// category in English regardless of ?locale=. So carry this page's cat dictionary through a data
|
|
45
49
|
// attribute (same channel as newLabel) and map through it — the English key stays the stable
|
|
@@ -73,7 +77,7 @@
|
|
|
73
77
|
const esc = (s: unknown): string =>
|
|
74
78
|
String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] as string);
|
|
75
79
|
|
|
76
|
-
type Entry = { handle: string; home: string; bio: string | null; name: string | null; avatar: string | null; glyph: string | null; category: string | null; accent: string | null; qrIcon: boolean; badge: string | null; createdAt: string | null };
|
|
80
|
+
type Entry = { handle: string; home: string; locales?: string[] | null; bio: string | null; name: string | null; avatar: string | null; glyph: string | null; category: string | null; accent: string | null; qrIcon: boolean; badge: string | null; createdAt: string | null };
|
|
77
81
|
const cardHTML = (a: Entry): string => {
|
|
78
82
|
const url = `https://${a.home}`;
|
|
79
83
|
// the persona's canonical icon, served on its own host — one face everywhere (avatar wins; glyph is gone).
|
|
@@ -133,7 +137,16 @@
|
|
|
133
137
|
el.setAttribute(scopeAttr, "");
|
|
134
138
|
el.querySelectorAll("*").forEach((n) => n.setAttribute(scopeAttr, ""));
|
|
135
139
|
}
|
|
136
|
-
|
|
140
|
+
// queries: ref=app.zone · lang parameter on a card · clean locale link · data-locale-links
|
|
141
|
+
//
|
|
142
|
+
// WHERE THE CARD GOES. An apex that has opted in (`localeLinks` on Catalogue) sends the visitor
|
|
143
|
+
// straight to the persona's page in this page's language — the canonical address, no query
|
|
144
|
+
// string; see ../personaHref.js for what the parameters cost in Search Console. An apex that
|
|
145
|
+
// has not keeps the bare root it was built with, because only the apex can vouch that its
|
|
146
|
+
// citizens speak its languages, and a persona without `/<xx>/` answers 404 there. Its
|
|
147
|
+
// visitor's language still travels: Catalogue.astro adds the hint at the moment of the tap,
|
|
148
|
+
// never into the page a crawler renders.
|
|
149
|
+
if (localeLinks) el.href = personaHref(a.home, locale, { locales: a.locales });
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
// The filter panel is rendered from the build-time seed, so a persona that registered afterwards
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// THE ADDRESS AN APEX HANDS A VISITOR WHEN IT SENDS THEM TO A PERSONA — one rule, one copy.
|
|
2
|
+
//
|
|
3
|
+
// queries: persona link · app.zone card href · ref=app.zone · lang parameter · locale link ·
|
|
4
|
+
// clean locale url · page with redirect in search console · parameterised url indexed ·
|
|
5
|
+
// how does a storefront link to a persona in the reader's language
|
|
6
|
+
//
|
|
7
|
+
// ── WHAT IT REPLACES, AND WHY ───────────────────────────────────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// A storefront used to link to `https://<home>/?ref=<apex>&lang=<xx>`. The persona's root then read
|
|
10
|
+
// `lang` as a hint and replaced the location with `/<xx>/?ref=…&lang=…` in the browser. For a person
|
|
11
|
+
// that is one extra hop. For a search engine it is a page whose HTML is the default language, whose
|
|
12
|
+
// canonical is the root, and whose script goes somewhere else — three statements that disagree.
|
|
13
|
+
//
|
|
14
|
+
// Measured in Search Console for app.zone on 2026-10-11: 232 of those addresses filed as "Page with
|
|
15
|
+
// redirect", 67 indexed as pages of their own, and for 49 of the 67 the real `/<xx>/` page was still
|
|
16
|
+
// waiting to be crawled. The parameterised address had taken the localized page's place.
|
|
17
|
+
//
|
|
18
|
+
// So the link is the page itself: `https://<home>/` for the default language, `https://<home>/<xx>/`
|
|
19
|
+
// for any other. No query string. `ref` had no reader — the arrival's own `document.referrer` already
|
|
20
|
+
// says which storefront it came from.
|
|
21
|
+
//
|
|
22
|
+
// ── THE ONE THING THIS CANNOT KNOW BY ITSELF ────────────────────────────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// Whether the persona HAS that language. A persona without `/<xx>/` answers 404 there, where the old
|
|
25
|
+
// hint was simply ignored. So:
|
|
26
|
+
// · `locales` given (the persona's own list) → the localized page only if it is in the list.
|
|
27
|
+
// · `locales` not given → the caller has vouched for it (see `localeLinks`
|
|
28
|
+
// on Catalogue: an apex opts in when its citizens
|
|
29
|
+
// all speak the apex's languages).
|
|
30
|
+
// A language code that is not a plain BCP-47 tag never reaches a path at all.
|
|
31
|
+
import { localePath } from "@justai/ui/locale";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} home the persona's host, e.g. "merge-pdf.app.zone" (a scheme or path is ignored)
|
|
35
|
+
* @param {string} lang the page's language
|
|
36
|
+
* @param {{ defaultLocale?: string, locales?: readonly string[] | null }} [opts]
|
|
37
|
+
* @returns {string} an absolute https URL with no query string and no fragment
|
|
38
|
+
*/
|
|
39
|
+
export function personaHref(home, lang, opts) {
|
|
40
|
+
const def = (opts && opts.defaultLocale) || "en";
|
|
41
|
+
const host = String(home || "").trim().replace(/^[a-z]+:\/\//i, "").replace(/[/?#].*$/, "").toLowerCase();
|
|
42
|
+
const code = String(lang || "").trim().toLowerCase();
|
|
43
|
+
const known = opts && Array.isArray(opts.locales) ? opts.locales : null;
|
|
44
|
+
const speaks = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(code) && (!known || known.indexOf(code) !== -1);
|
|
45
|
+
return `https://${host}${localePath(speaks ? code : def, def)}`;
|
|
46
|
+
}
|