@submitium/badges 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +137 -2
- package/astro/BadgeWall.astro +12 -0
- package/astro/Node.astro +24 -0
- package/astro/SubmitiumBadges.astro +11 -0
- package/client-iCo9PkAU.d.ts +21 -0
- package/client-x4bD7iXW.js +246 -0
- package/contract-KBbC2LBR.d.ts +59 -0
- package/index.d.ts +79 -0
- package/index.js +48 -0
- package/layout-DvNYYz0J.js +297 -0
- package/package.json +62 -4
- package/react.d.ts +21 -0
- package/react.js +37 -0
- package/svelte/BadgeWall.svelte +10 -0
- package/svelte/Node.svelte +22 -0
- package/vue.d.ts +18 -0
- package/vue.js +22 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Submitium
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @submitium/badges
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Submitium badge wall: the official badge of every directory that lists your product, in one block on your homepage. Submitium adds and removes badges as your listings change, so you never paste a badge again.
|
|
4
|
+
|
|
5
|
+
Your server fetches the wall with your project's API key. The package checks our signature and the data, then renders plain links and images with your framework's own elements. We never send HTML, CSS or scripts: no `dangerouslySetInnerHTML`, no `v-html`, no `{@html}`, nothing that runs in the browser.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @submitium/badges
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Create an API key on the Badge wall page of your Submitium dashboard, and put it in your server's environment:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
SUBMITIUM_API_KEY=sbk_live_…
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It is a secret: keep it on the server. It can only read this project's badge wall. Never expose it to the browser (no `NEXT_PUBLIC_`, `PUBLIC_` or `VITE_` prefix). Revoke it from the dashboard if it leaks; the site shows no wall until it has a new key.
|
|
20
|
+
|
|
21
|
+
Put the wall on your **homepage**, usually in the footer. That's the page directories check.
|
|
22
|
+
|
|
23
|
+
## Next.js (App Router)
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import { SubmitiumBadges } from "@submitium/badges/react";
|
|
27
|
+
|
|
28
|
+
export function Footer() {
|
|
29
|
+
return (
|
|
30
|
+
<footer>
|
|
31
|
+
<SubmitiumBadges />
|
|
32
|
+
</footer>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`<SubmitiumBadges />` is an async Server Component. It reads `SUBMITIUM_API_KEY`, and Next.js caches the wall for an hour (`revalidate`).
|
|
38
|
+
|
|
39
|
+
## Nuxt
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// server/api/badges.get.ts
|
|
43
|
+
import { fetchWall } from "@submitium/badges";
|
|
44
|
+
|
|
45
|
+
export default defineEventHandler(() => fetchWall());
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```vue
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import { BadgeWall } from "@submitium/badges/vue";
|
|
51
|
+
|
|
52
|
+
const { data: wall } = await useFetch("/api/badges");
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<footer>
|
|
57
|
+
<BadgeWall v-if="wall" :wall="wall" />
|
|
58
|
+
</footer>
|
|
59
|
+
</template>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## SvelteKit
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// src/routes/+layout.server.ts
|
|
66
|
+
import { fetchWall } from "@submitium/badges";
|
|
67
|
+
import { env } from "$env/dynamic/private";
|
|
68
|
+
|
|
69
|
+
export async function load() {
|
|
70
|
+
return { wall: await fetchWall({ apiKey: env.SUBMITIUM_API_KEY }) };
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```svelte
|
|
75
|
+
<script lang="ts">
|
|
76
|
+
import BadgeWall from "@submitium/badges/svelte";
|
|
77
|
+
|
|
78
|
+
let { data, children } = $props();
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
{@render children()}
|
|
82
|
+
|
|
83
|
+
<footer>
|
|
84
|
+
{#if data.wall}
|
|
85
|
+
<BadgeWall wall={data.wall} />
|
|
86
|
+
{/if}
|
|
87
|
+
</footer>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Astro
|
|
91
|
+
|
|
92
|
+
```astro
|
|
93
|
+
---
|
|
94
|
+
import SubmitiumBadges from "@submitium/badges/astro";
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
<footer>
|
|
98
|
+
<SubmitiumBadges />
|
|
99
|
+
</footer>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It reads `SUBMITIUM_API_KEY` from `import.meta.env`. A static build fetches the wall when it builds: rebuild once a day to keep it current.
|
|
103
|
+
|
|
104
|
+
## Any other server
|
|
105
|
+
|
|
106
|
+
`fetchWall()` returns the checked wall, or `null`. `wallToHtml(layoutWall(wall))` turns it into an HTML string where every value is escaped, to print in your template.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { fetchWall, layoutWall, wallToHtml } from "@submitium/badges";
|
|
110
|
+
|
|
111
|
+
const wall = await fetchWall();
|
|
112
|
+
const html = wall ? wallToHtml(layoutWall(wall)) : "";
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## `fetchWall` options
|
|
116
|
+
|
|
117
|
+
| Option | Default | |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `apiKey` | `SUBMITIUM_API_KEY` | Your project's API key. |
|
|
120
|
+
| `timeout` | `3000` | Milliseconds to wait for the API. Past that, no wall, and your page carries on. |
|
|
121
|
+
| `revalidate` | `3600` | Seconds a wall is kept before asking again (in memory, and as Next.js `revalidate`). |
|
|
122
|
+
| `apiUrl` | `https://api.submitium.com` | For local development only. |
|
|
123
|
+
|
|
124
|
+
The format (`ribbon`: one gliding line in the footer bar; `window`: a small gliding window under your logo), the theme and the attribution link are settings you change in the dashboard. Your code stays the same.
|
|
125
|
+
|
|
126
|
+
## What the package guarantees
|
|
127
|
+
|
|
128
|
+
- **Signed.** Every wall is signed with Ed25519. The package checks the signature against our public keys, pinned in its code, before it reads anything. A wall from anyone else, or changed on the way, renders nothing.
|
|
129
|
+
- **Checked.** The wall must match a strict contract (`parseWall`): https links that point to the directory's own domain, https images, a closed list of `rel` values, plain text with a length limit, and a wall that hasn't expired (a week at most). Anything else and the whole wall is refused, not repaired.
|
|
130
|
+
- **Plain elements only.** The wall becomes links, images, a few spans and one scoped `<style>`, all written by this package from numbers (no CSS comes from the API). React, Vue, Svelte and Astro write each element themselves and escape every value.
|
|
131
|
+
- **In your HTML.** The badges are in the HTML your server sends, which is what directory checkers read. No client JavaScript: motion is pure CSS. It pauses on hover and keyboard focus, and stands still under `prefers-reduced-motion`.
|
|
132
|
+
- **Never breaks your page.** If the API is slow, down or answers anything unexpected, the last good wall is kept, or nothing renders.
|
|
133
|
+
- **Fair to directories.** Each badge links to its directory with the directory's official `rel`, the image loads from the directory's own URL, and no badge is hidden or duplicated. Badges a directory wants in view at all times never move.
|
|
134
|
+
- **Attribution.** One discreet "Badge wall by Submitium" link, dofollow by default; you can make it nofollow in the dashboard.
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT. Source and how it works: https://submitium.com/badge-wall
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
// A wall you already fetched (with `fetchWall`), written as plain links and images.
|
|
3
|
+
import { layoutWall, type Wall } from "../index.js";
|
|
4
|
+
import Node from "./Node.astro";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
wall: Wall;
|
|
8
|
+
}
|
|
9
|
+
const { wall } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<Node node={layoutWall(wall)} />
|
package/astro/Node.astro
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
// One element of the wall's tree, written by Astro: only the elements and attributes the tree
|
|
3
|
+
// allows (TAGS, attrsOf), every value escaped. No HTML string is ever inserted.
|
|
4
|
+
import { attrsOf, cssOf, TAGS, type WallNode } from "../index.js";
|
|
5
|
+
import Node from "./Node.astro";
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
node: WallNode;
|
|
9
|
+
}
|
|
10
|
+
const { node } = Astro.props;
|
|
11
|
+
const Tag = node.tag;
|
|
12
|
+
const attrs = Object.fromEntries(attrsOf(node));
|
|
13
|
+
const empty = ["img", "source", "input", "path"].includes(node.tag);
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
!TAGS.has(node.tag) ? null : node.tag === "style" ? (
|
|
18
|
+
<style is:inline set:text={cssOf(node)} />
|
|
19
|
+
) : empty ? (
|
|
20
|
+
<Tag {...attrs} />
|
|
21
|
+
) : (
|
|
22
|
+
<Tag {...attrs}>{node.children.map((child) => (typeof child === "string" ? child : <Node node={child} />))}</Tag>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Astro: the wall is fetched while the page renders on your server (SSR or at build), with your
|
|
3
|
+
// API key from SUBMITIUM_API_KEY, checked, and written as plain links and images.
|
|
4
|
+
import { fetchWall, type FetchWallOptions } from "../index.js";
|
|
5
|
+
import BadgeWall from "./BadgeWall.astro";
|
|
6
|
+
|
|
7
|
+
type Props = FetchWallOptions;
|
|
8
|
+
const wall = await fetchWall({ apiKey: import.meta.env.SUBMITIUM_API_KEY, ...Astro.props });
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
{wall && <BadgeWall wall={wall} />}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { d as Wall } from "./contract-KBbC2LBR.js";
|
|
2
|
+
//#region src/client.d.ts
|
|
3
|
+
declare const DEFAULT_API_URL = "https://api.submitium.com";
|
|
4
|
+
type FetchWallOptions = {
|
|
5
|
+
/** Your project's API key. Defaults to the SUBMITIUM_API_KEY environment variable. */
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
/** Milliseconds before giving up: the page renders without the wall. Default 3000. */
|
|
8
|
+
timeout?: number;
|
|
9
|
+
/** Seconds the wall is kept before asking again (in memory, and as Next.js `revalidate`). Default one hour. */
|
|
10
|
+
revalidate?: number;
|
|
11
|
+
/** Testing against a local API only: its URL and its public key. */
|
|
12
|
+
apiUrl?: string;
|
|
13
|
+
publicKeys?: Readonly<Record<string, string>>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The wall, verified and checked, or null: no key, the API slow or down with no good copy kept,
|
|
17
|
+
* a bad signature or a wall outside the contract. A site never breaks or waits because of it.
|
|
18
|
+
*/
|
|
19
|
+
declare function fetchWall(options?: FetchWallOptions): Promise<Wall | null>;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { FetchWallOptions as n, fetchWall as r, DEFAULT_API_URL as t };
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
//#region src/contract.ts
|
|
2
|
+
const WALL_VERSION = 1;
|
|
3
|
+
const VARIANTS = ["ribbon", "window"];
|
|
4
|
+
const THEMES = [
|
|
5
|
+
"light",
|
|
6
|
+
"dark",
|
|
7
|
+
"auto"
|
|
8
|
+
];
|
|
9
|
+
/** The "Badge wall by Submitium" line: dofollow (default), nofollow, or left out. */
|
|
10
|
+
const ATTRIBUTIONS = [
|
|
11
|
+
"dofollow",
|
|
12
|
+
"nofollow",
|
|
13
|
+
"none"
|
|
14
|
+
];
|
|
15
|
+
/** The only `rel` tokens a badge link may carry. No rel at all is a followed link. */
|
|
16
|
+
const REL_TOKENS = [
|
|
17
|
+
"nofollow",
|
|
18
|
+
"noopener",
|
|
19
|
+
"noreferrer",
|
|
20
|
+
"sponsored",
|
|
21
|
+
"ugc"
|
|
22
|
+
];
|
|
23
|
+
const LIMITS = {
|
|
24
|
+
badges: 300,
|
|
25
|
+
text: 120,
|
|
26
|
+
url: 2048,
|
|
27
|
+
id: 80,
|
|
28
|
+
size: 1e3,
|
|
29
|
+
/** Bytes of the payload. */
|
|
30
|
+
payload: 524288
|
|
31
|
+
};
|
|
32
|
+
const KEY = /^bk_[A-Za-z0-9]{10,40}$/;
|
|
33
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,79}$/;
|
|
34
|
+
const DOMAIN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
35
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
36
|
+
const hasOnly = (o, keys) => Object.keys(o).every((k) => keys.includes(k));
|
|
37
|
+
/** Plain text, no control characters, within the length limit. */
|
|
38
|
+
function text(v, max = LIMITS.text) {
|
|
39
|
+
if (typeof v !== "string" || v.length === 0 || v.length > max) return null;
|
|
40
|
+
return /[\u0000-\u001f\u007f]/.test(v) ? null : v;
|
|
41
|
+
}
|
|
42
|
+
/** An absolute https URL with no credentials and the default port. */
|
|
43
|
+
function httpsUrl(v) {
|
|
44
|
+
const raw = text(v, LIMITS.url);
|
|
45
|
+
if (!raw) return null;
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(raw);
|
|
48
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port) return null;
|
|
49
|
+
return url;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const sameSite = (host, domain) => host === domain || host.endsWith(`.${domain}`);
|
|
55
|
+
function size(v) {
|
|
56
|
+
if (v === null) return null;
|
|
57
|
+
return Number.isInteger(v) && v > 0 && v <= LIMITS.size ? v : void 0;
|
|
58
|
+
}
|
|
59
|
+
function badge(v) {
|
|
60
|
+
if (!isObj(v) || !hasOnly(v, [
|
|
61
|
+
"id",
|
|
62
|
+
"name",
|
|
63
|
+
"domain",
|
|
64
|
+
"href",
|
|
65
|
+
"rel",
|
|
66
|
+
"still",
|
|
67
|
+
"image"
|
|
68
|
+
])) return null;
|
|
69
|
+
const id = typeof v.id === "string" && ID.test(v.id) ? v.id : null;
|
|
70
|
+
const name = text(v.name);
|
|
71
|
+
const domain = typeof v.domain === "string" && DOMAIN.test(v.domain) ? v.domain : null;
|
|
72
|
+
const href = httpsUrl(v.href);
|
|
73
|
+
if (!id || !name || !domain || !href || !sameSite(href.hostname.replace(/^www\./, ""), domain)) return null;
|
|
74
|
+
if (!Array.isArray(v.rel) || v.rel.length > REL_TOKENS.length || !v.rel.every((t) => REL_TOKENS.includes(t))) return null;
|
|
75
|
+
if (typeof v.still !== "boolean") return null;
|
|
76
|
+
const img = v.image;
|
|
77
|
+
if (!isObj(img) || !hasOnly(img, [
|
|
78
|
+
"src",
|
|
79
|
+
"srcDark",
|
|
80
|
+
"width",
|
|
81
|
+
"height",
|
|
82
|
+
"alt"
|
|
83
|
+
])) return null;
|
|
84
|
+
const src = httpsUrl(img.src);
|
|
85
|
+
const srcDark = img.srcDark === null ? null : httpsUrl(img.srcDark);
|
|
86
|
+
const width = size(img.width);
|
|
87
|
+
const height = size(img.height);
|
|
88
|
+
const alt = text(img.alt);
|
|
89
|
+
if (!src || srcDark === void 0 || img.srcDark !== null && !srcDark || width === void 0 || height === void 0 || !alt) return null;
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
name,
|
|
93
|
+
domain,
|
|
94
|
+
href: href.toString(),
|
|
95
|
+
rel: [...new Set(v.rel)],
|
|
96
|
+
still: v.still,
|
|
97
|
+
image: {
|
|
98
|
+
src: src.toString(),
|
|
99
|
+
srcDark: srcDark ? srcDark.toString() : null,
|
|
100
|
+
width,
|
|
101
|
+
height,
|
|
102
|
+
alt
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** The wall, checked against the contract; null when anything in it is off. */
|
|
107
|
+
function parseWall(value, now = /* @__PURE__ */ new Date()) {
|
|
108
|
+
if (!isObj(value) || !hasOnly(value, [
|
|
109
|
+
"v",
|
|
110
|
+
"key",
|
|
111
|
+
"issuedAt",
|
|
112
|
+
"expiresAt",
|
|
113
|
+
"settings",
|
|
114
|
+
"badges"
|
|
115
|
+
])) return null;
|
|
116
|
+
if (value.v !== 1 || typeof value.key !== "string" || !KEY.test(value.key)) return null;
|
|
117
|
+
const issued = typeof value.issuedAt === "string" ? Date.parse(value.issuedAt) : NaN;
|
|
118
|
+
const expires = typeof value.expiresAt === "string" ? Date.parse(value.expiresAt) : NaN;
|
|
119
|
+
if (Number.isNaN(issued) || Number.isNaN(expires) || expires <= issued || now.getTime() > expires) return null;
|
|
120
|
+
const s = value.settings;
|
|
121
|
+
if (!isObj(s) || !hasOnly(s, [
|
|
122
|
+
"variant",
|
|
123
|
+
"theme",
|
|
124
|
+
"attribution"
|
|
125
|
+
])) return null;
|
|
126
|
+
if (!VARIANTS.includes(s.variant) || !THEMES.includes(s.theme) || !ATTRIBUTIONS.includes(s.attribution)) return null;
|
|
127
|
+
if (!Array.isArray(value.badges) || value.badges.length > LIMITS.badges) return null;
|
|
128
|
+
const badges = [];
|
|
129
|
+
const seen = /* @__PURE__ */ new Set();
|
|
130
|
+
for (const raw of value.badges) {
|
|
131
|
+
const b = badge(raw);
|
|
132
|
+
if (!b || seen.has(b.id)) return null;
|
|
133
|
+
seen.add(b.id);
|
|
134
|
+
badges.push(b);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
v: 1,
|
|
138
|
+
key: value.key,
|
|
139
|
+
issuedAt: new Date(issued).toISOString(),
|
|
140
|
+
expiresAt: new Date(expires).toISOString(),
|
|
141
|
+
settings: {
|
|
142
|
+
variant: s.variant,
|
|
143
|
+
theme: s.theme,
|
|
144
|
+
attribution: s.attribution
|
|
145
|
+
},
|
|
146
|
+
badges
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/signature.ts
|
|
151
|
+
/**
|
|
152
|
+
* Submitium's public keys, by id. A new key is added here before the API starts signing with it,
|
|
153
|
+
* and an old one stays until every site has had time to update the package.
|
|
154
|
+
*/
|
|
155
|
+
const SUBMITIUM_PUBLIC_KEYS = { "sbk-2026-10": "z-donEWLI7VYCSsaJgGOhih66KQLHiZJbG5DV4b2fqQ" };
|
|
156
|
+
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
157
|
+
function fromBase64Url(value) {
|
|
158
|
+
if (!B64URL.test(value)) return null;
|
|
159
|
+
const b64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
160
|
+
try {
|
|
161
|
+
const bin = atob(b64);
|
|
162
|
+
const out = new Uint8Array(new ArrayBuffer(bin.length));
|
|
163
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
164
|
+
return out;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function isSignedWall(value) {
|
|
170
|
+
if (typeof value !== "object" || value === null) return false;
|
|
171
|
+
const v = value;
|
|
172
|
+
return Object.keys(v).length === 3 && typeof v.payload === "string" && typeof v.signature === "string" && typeof v.keyId === "string" && /^[a-z0-9-]{1,40}$/.test(v.keyId);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The wall's JSON text when the signature is good, else null. `keys` defaults to Submitium's;
|
|
176
|
+
* pass your own only to test against a local API.
|
|
177
|
+
*/
|
|
178
|
+
async function verifySignedWall(envelope, keys = SUBMITIUM_PUBLIC_KEYS) {
|
|
179
|
+
const publicKey = keys[envelope.keyId];
|
|
180
|
+
if (!publicKey) return null;
|
|
181
|
+
const raw = fromBase64Url(publicKey);
|
|
182
|
+
const signature = fromBase64Url(envelope.signature);
|
|
183
|
+
if (!raw || raw.length !== 32 || !signature || signature.length !== 64) return null;
|
|
184
|
+
try {
|
|
185
|
+
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
186
|
+
return await crypto.subtle.verify({ name: "Ed25519" }, key, signature, new TextEncoder().encode(envelope.payload)) ? envelope.payload : null;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/client.ts
|
|
193
|
+
const DEFAULT_API_URL = "https://api.submitium.com";
|
|
194
|
+
const API_KEY = /^sbk_(live|test)_[A-Za-z0-9]{24,64}$/;
|
|
195
|
+
function envKey() {
|
|
196
|
+
return typeof process !== "undefined" ? process.env?.SUBMITIUM_API_KEY : void 0;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The last good wall of each key, in this server's memory: a busy page doesn't ask us on every
|
|
200
|
+
* request, and keeps its badges while our API is down (until the wall itself expires).
|
|
201
|
+
*/
|
|
202
|
+
const memory = /* @__PURE__ */ new Map();
|
|
203
|
+
const alive = (wall) => Date.parse(wall.expiresAt) > Date.now();
|
|
204
|
+
/**
|
|
205
|
+
* The wall, verified and checked, or null: no key, the API slow or down with no good copy kept,
|
|
206
|
+
* a bad signature or a wall outside the contract. A site never breaks or waits because of it.
|
|
207
|
+
*/
|
|
208
|
+
async function fetchWall(options = {}) {
|
|
209
|
+
const apiKey = options.apiKey ?? envKey();
|
|
210
|
+
if (!apiKey || !API_KEY.test(apiKey)) return null;
|
|
211
|
+
const base = (options.apiUrl ?? "https://api.submitium.com").replace(/\/+$/, "");
|
|
212
|
+
const revalidate = options.revalidate ?? 3600;
|
|
213
|
+
const slot = `${base} ${apiKey}`;
|
|
214
|
+
const kept = memory.get(slot);
|
|
215
|
+
if (kept && alive(kept.wall) && Date.now() - kept.at < revalidate * 1e3) return kept.wall;
|
|
216
|
+
const fresh = await request(base, apiKey, revalidate, options);
|
|
217
|
+
if (fresh) memory.set(slot, {
|
|
218
|
+
wall: fresh,
|
|
219
|
+
at: Date.now()
|
|
220
|
+
});
|
|
221
|
+
return fresh ?? (kept && alive(kept.wall) ? kept.wall : null);
|
|
222
|
+
}
|
|
223
|
+
async function request(base, apiKey, revalidate, options) {
|
|
224
|
+
try {
|
|
225
|
+
const init = {
|
|
226
|
+
headers: {
|
|
227
|
+
authorization: `Bearer ${apiKey}`,
|
|
228
|
+
accept: "application/json"
|
|
229
|
+
},
|
|
230
|
+
signal: AbortSignal.timeout(options.timeout ?? 3e3),
|
|
231
|
+
next: { revalidate }
|
|
232
|
+
};
|
|
233
|
+
const res = await fetch(`${base}/v1/wall`, init);
|
|
234
|
+
if (!res.ok) return null;
|
|
235
|
+
const body = await res.text();
|
|
236
|
+
if (body.length > LIMITS.payload * 2) return null;
|
|
237
|
+
const envelope = JSON.parse(body);
|
|
238
|
+
if (!isSignedWall(envelope) || envelope.payload.length > LIMITS.payload) return null;
|
|
239
|
+
const payload = await verifySignedWall(envelope, options.publicKeys);
|
|
240
|
+
return payload ? parseWall(JSON.parse(payload)) : null;
|
|
241
|
+
} catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
//#endregion
|
|
246
|
+
export { verifySignedWall as a, REL_TOKENS as c, WALL_VERSION as d, parseWall as f, isSignedWall as i, THEMES as l, fetchWall as n, ATTRIBUTIONS as o, SUBMITIUM_PUBLIC_KEYS as r, LIMITS as s, DEFAULT_API_URL as t, VARIANTS as u };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//#region src/contract.d.ts
|
|
2
|
+
declare const WALL_VERSION = 1;
|
|
3
|
+
declare const VARIANTS: readonly ["ribbon", "window"];
|
|
4
|
+
type Variant = (typeof VARIANTS)[number];
|
|
5
|
+
declare const THEMES: readonly ["light", "dark", "auto"];
|
|
6
|
+
type Theme = (typeof THEMES)[number];
|
|
7
|
+
/** The "Badge wall by Submitium" line: dofollow (default), nofollow, or left out. */
|
|
8
|
+
declare const ATTRIBUTIONS: readonly ["dofollow", "nofollow", "none"];
|
|
9
|
+
type Attribution = (typeof ATTRIBUTIONS)[number];
|
|
10
|
+
/** The only `rel` tokens a badge link may carry. No rel at all is a followed link. */
|
|
11
|
+
declare const REL_TOKENS: readonly ["nofollow", "noopener", "noreferrer", "sponsored", "ugc"];
|
|
12
|
+
type RelToken = (typeof REL_TOKENS)[number];
|
|
13
|
+
type WallBadge = {
|
|
14
|
+
/** The directory's id at Submitium. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** The directory's name, for the image's fallback text. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** The directory's domain: the link must point there (or to a subdomain). */
|
|
19
|
+
domain: string;
|
|
20
|
+
href: string;
|
|
21
|
+
rel: RelToken[];
|
|
22
|
+
/** The directory checks the badge is visible at rest: it never sits in a moving part. */
|
|
23
|
+
still: boolean;
|
|
24
|
+
image: {
|
|
25
|
+
src: string;
|
|
26
|
+
/** For dark sites, when the directory gives one. */
|
|
27
|
+
srcDark: string | null;
|
|
28
|
+
width: number | null;
|
|
29
|
+
height: number | null;
|
|
30
|
+
alt: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
type Wall = {
|
|
34
|
+
v: typeof WALL_VERSION;
|
|
35
|
+
/** The project's public badge key (`bk_…`): our checker finds the wall by it. */
|
|
36
|
+
key: string;
|
|
37
|
+
issuedAt: string;
|
|
38
|
+
/** A signed wall older than this is refused: an old copy can't be replayed forever. */
|
|
39
|
+
expiresAt: string;
|
|
40
|
+
settings: {
|
|
41
|
+
variant: Variant;
|
|
42
|
+
theme: Theme;
|
|
43
|
+
attribution: Attribution;
|
|
44
|
+
};
|
|
45
|
+
badges: WallBadge[];
|
|
46
|
+
};
|
|
47
|
+
declare const LIMITS: {
|
|
48
|
+
readonly badges: 300;
|
|
49
|
+
readonly text: 120;
|
|
50
|
+
readonly url: 2048;
|
|
51
|
+
readonly id: 80;
|
|
52
|
+
readonly size: 1000;
|
|
53
|
+
/** Bytes of the payload. */
|
|
54
|
+
readonly payload: number;
|
|
55
|
+
};
|
|
56
|
+
/** The wall, checked against the contract; null when anything in it is off. */
|
|
57
|
+
declare function parseWall(value: unknown, now?: Date): Wall | null;
|
|
58
|
+
//#endregion
|
|
59
|
+
export { RelToken as a, VARIANTS as c, Wall as d, WallBadge as f, REL_TOKENS as i, Variant as l, Attribution as n, THEMES as o, parseWall as p, LIMITS as r, Theme as s, ATTRIBUTIONS as t, WALL_VERSION as u };
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { a as RelToken, c as VARIANTS, d as Wall, f as WallBadge, i as REL_TOKENS, l as Variant, n as Attribution, o as THEMES, p as parseWall, r as LIMITS, s as Theme, t as ATTRIBUTIONS, u as WALL_VERSION } from "./contract-KBbC2LBR.js";
|
|
2
|
+
import { n as FetchWallOptions, r as fetchWall, t as DEFAULT_API_URL } from "./client-iCo9PkAU.js";
|
|
3
|
+
//#region src/layout.d.ts
|
|
4
|
+
type WallTag = "div" | "ul" | "li" | "a" | "img" | "picture" | "source" | "span" | "b" | "style" | "svg" | "path";
|
|
5
|
+
type WallAttrs = {
|
|
6
|
+
class?: string;
|
|
7
|
+
/** CSS properties, kebab-case, values written by this file. */
|
|
8
|
+
style?: Record<string, string>;
|
|
9
|
+
href?: string;
|
|
10
|
+
target?: "_blank";
|
|
11
|
+
rel?: string;
|
|
12
|
+
src?: string;
|
|
13
|
+
srcset?: string;
|
|
14
|
+
media?: string;
|
|
15
|
+
alt?: string;
|
|
16
|
+
width?: number;
|
|
17
|
+
height?: number;
|
|
18
|
+
loading?: "lazy";
|
|
19
|
+
decoding?: "async";
|
|
20
|
+
"aria-label"?: string;
|
|
21
|
+
"aria-hidden"?: "true";
|
|
22
|
+
viewBox?: string;
|
|
23
|
+
xmlns?: "http://www.w3.org/2000/svg";
|
|
24
|
+
fill?: string;
|
|
25
|
+
d?: string;
|
|
26
|
+
"data-submitium"?: string;
|
|
27
|
+
"data-submitium-variant"?: Variant;
|
|
28
|
+
"data-submitium-zone"?: "moving" | "fixed";
|
|
29
|
+
};
|
|
30
|
+
type WallNode = {
|
|
31
|
+
tag: WallTag;
|
|
32
|
+
attrs: WallAttrs;
|
|
33
|
+
children: (WallNode | string)[];
|
|
34
|
+
};
|
|
35
|
+
export declare const ATTRIBUTION_URL = "https://submitium.com/?ref=badge-wall";
|
|
36
|
+
/**
|
|
37
|
+
* The wall as a tree. An empty wall (nothing ordered yet) is its root alone: invisible, and still
|
|
38
|
+
* found by our check, which looks for the project's key on it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function layoutWall(wall: Wall): WallNode;
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/html.d.ts
|
|
43
|
+
/**
|
|
44
|
+
* The wall as static HTML. `pretty` puts each block on its own line and each CSS rule on its own
|
|
45
|
+
* line, for a copy people read before they paste it; it renders the same.
|
|
46
|
+
*/
|
|
47
|
+
export declare function wallToHtml(node: WallNode, options?: {
|
|
48
|
+
pretty?: boolean;
|
|
49
|
+
}): string;
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/signature.d.ts
|
|
52
|
+
/** The envelope the API answers with. `payload` is the wall's JSON, as text, byte for byte. */
|
|
53
|
+
type SignedWall = {
|
|
54
|
+
payload: string;
|
|
55
|
+
signature: string;
|
|
56
|
+
keyId: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Submitium's public keys, by id. A new key is added here before the API starts signing with it,
|
|
60
|
+
* and an old one stays until every site has had time to update the package.
|
|
61
|
+
*/
|
|
62
|
+
export declare const SUBMITIUM_PUBLIC_KEYS: Readonly<Record<string, string>>;
|
|
63
|
+
export declare function isSignedWall(value: unknown): value is SignedWall;
|
|
64
|
+
/**
|
|
65
|
+
* The wall's JSON text when the signature is good, else null. `keys` defaults to Submitium's;
|
|
66
|
+
* pass your own only to test against a local API.
|
|
67
|
+
*/
|
|
68
|
+
export declare function verifySignedWall(envelope: SignedWall, keys?: Readonly<Record<string, string>>): Promise<string | null>;
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/tree.d.ts
|
|
71
|
+
export declare const TAGS: ReadonlySet<WallTag>;
|
|
72
|
+
export declare const ATTRS: ReadonlySet<keyof WallAttrs>;
|
|
73
|
+
/** The node's attributes that may be written, `style` as a CSS text. */
|
|
74
|
+
export declare function attrsOf(node: WallNode): [string, string | number][];
|
|
75
|
+
export declare function styleText(style: Record<string, string>): string;
|
|
76
|
+
/** The CSS of a `style` node, refused if it could close the element (it never can: it's ours). */
|
|
77
|
+
export declare function cssOf(node: WallNode): string;
|
|
78
|
+
//#endregion
|
|
79
|
+
export { ATTRIBUTIONS, type Attribution, DEFAULT_API_URL, type FetchWallOptions, LIMITS, REL_TOKENS, type RelToken, type SignedWall, THEMES, type Theme, VARIANTS, type Variant, WALL_VERSION, type Wall, type WallAttrs, type WallBadge, type WallNode, type WallTag, fetchWall, parseWall };
|
package/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { a as verifySignedWall, c as REL_TOKENS, d as WALL_VERSION, f as parseWall, i as isSignedWall, l as THEMES, n as fetchWall, o as ATTRIBUTIONS, r as SUBMITIUM_PUBLIC_KEYS, s as LIMITS, t as DEFAULT_API_URL, u as VARIANTS } from "./client-x4bD7iXW.js";
|
|
2
|
+
import { a as attrsOf, i as TAGS, n as layoutWall, o as cssOf, r as ATTRS, s as styleText, t as ATTRIBUTION_URL } from "./layout-DvNYYz0J.js";
|
|
3
|
+
//#region src/html.ts
|
|
4
|
+
const VOID = /* @__PURE__ */ new Set([
|
|
5
|
+
"img",
|
|
6
|
+
"source",
|
|
7
|
+
"path"
|
|
8
|
+
]);
|
|
9
|
+
function escape(value) {
|
|
10
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
11
|
+
}
|
|
12
|
+
/** Elements whose children sit on their own lines when pretty: block and flex boxes, where the whitespace never shows. */
|
|
13
|
+
const BLOCKS = /* @__PURE__ */ new Set(["div", "ul"]);
|
|
14
|
+
/** The CSS one rule a line (a media query and its rules on one), to read before pasting. */
|
|
15
|
+
function cssLines(css, pad) {
|
|
16
|
+
const rules = [];
|
|
17
|
+
let depth = 0;
|
|
18
|
+
let start = 0;
|
|
19
|
+
for (let i = 0; i < css.length; i++) if (css[i] === "{") depth++;
|
|
20
|
+
else if (css[i] === "}" && --depth === 0) {
|
|
21
|
+
rules.push(css.slice(start, i + 1));
|
|
22
|
+
start = i + 1;
|
|
23
|
+
}
|
|
24
|
+
return rules.map((r) => `\n${pad}${r}`).join("");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The wall as static HTML. `pretty` puts each block on its own line and each CSS rule on its own
|
|
28
|
+
* line, for a copy people read before they paste it; it renders the same.
|
|
29
|
+
*/
|
|
30
|
+
function wallToHtml(node, options = {}) {
|
|
31
|
+
return write(node, options.pretty ? 0 : null);
|
|
32
|
+
}
|
|
33
|
+
function write(node, depth) {
|
|
34
|
+
if (!TAGS.has(node.tag)) return "";
|
|
35
|
+
const pad = depth === null ? "" : " ".repeat(depth + 1);
|
|
36
|
+
const end = depth === null ? "" : `\n${" ".repeat(depth)}`;
|
|
37
|
+
const attrs = attrsOf(node).map(([name, value]) => ` ${name}="${escape(String(value))}"`).join("");
|
|
38
|
+
if (node.tag === "style") return depth === null ? `<style>${cssOf(node)}</style>` : `<style>${cssLines(cssOf(node), pad)}${end}</style>`;
|
|
39
|
+
if (VOID.has(node.tag)) return node.tag === "path" ? `<path${attrs}/>` : `<${node.tag}${attrs}>`;
|
|
40
|
+
const lines = depth !== null && BLOCKS.has(node.tag) && node.children.length > 0;
|
|
41
|
+
const inner = node.children.map((c) => {
|
|
42
|
+
const html = typeof c === "string" ? escape(c) : write(c, lines ? depth + 1 : null);
|
|
43
|
+
return lines ? `\n${pad}${html}` : html;
|
|
44
|
+
}).join("");
|
|
45
|
+
return `<${node.tag}${attrs}>${inner}${lines ? end : ""}</${node.tag}>`;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { ATTRIBUTIONS, ATTRIBUTION_URL, ATTRS, DEFAULT_API_URL, LIMITS, REL_TOKENS, SUBMITIUM_PUBLIC_KEYS, TAGS, THEMES, VARIANTS, WALL_VERSION, attrsOf, cssOf, fetchWall, isSignedWall, layoutWall, parseWall, styleText, verifySignedWall, wallToHtml };
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
//#region src/tree.ts
|
|
2
|
+
const TAGS = /* @__PURE__ */ new Set([
|
|
3
|
+
"div",
|
|
4
|
+
"ul",
|
|
5
|
+
"li",
|
|
6
|
+
"a",
|
|
7
|
+
"img",
|
|
8
|
+
"picture",
|
|
9
|
+
"source",
|
|
10
|
+
"span",
|
|
11
|
+
"b",
|
|
12
|
+
"style",
|
|
13
|
+
"svg",
|
|
14
|
+
"path"
|
|
15
|
+
]);
|
|
16
|
+
const ATTRS = /* @__PURE__ */ new Set([
|
|
17
|
+
"class",
|
|
18
|
+
"style",
|
|
19
|
+
"href",
|
|
20
|
+
"target",
|
|
21
|
+
"rel",
|
|
22
|
+
"src",
|
|
23
|
+
"srcset",
|
|
24
|
+
"media",
|
|
25
|
+
"alt",
|
|
26
|
+
"width",
|
|
27
|
+
"height",
|
|
28
|
+
"loading",
|
|
29
|
+
"decoding",
|
|
30
|
+
"aria-label",
|
|
31
|
+
"aria-hidden",
|
|
32
|
+
"viewBox",
|
|
33
|
+
"xmlns",
|
|
34
|
+
"fill",
|
|
35
|
+
"d",
|
|
36
|
+
"data-submitium",
|
|
37
|
+
"data-submitium-variant",
|
|
38
|
+
"data-submitium-zone"
|
|
39
|
+
]);
|
|
40
|
+
/** The node's attributes that may be written, `style` as a CSS text. */
|
|
41
|
+
function attrsOf(node) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const [name, value] of Object.entries(node.attrs)) {
|
|
44
|
+
if (!ATTRS.has(name) || value === void 0) continue;
|
|
45
|
+
if (name === "style") out.push([name, styleText(value)]);
|
|
46
|
+
else if (typeof value === "string" || typeof value === "number") out.push([name, value]);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
function styleText(style) {
|
|
51
|
+
return Object.entries(style).filter(([k, v]) => /^[a-z-]+$/.test(k) && /^[\w\s.,%()#-]+$/.test(v)).map(([k, v]) => `${k}:${v}`).join(";");
|
|
52
|
+
}
|
|
53
|
+
/** The CSS of a `style` node, refused if it could close the element (it never can: it's ours). */
|
|
54
|
+
function cssOf(node) {
|
|
55
|
+
const css = node.children.filter((c) => typeof c === "string").join("");
|
|
56
|
+
return /[<>"'&\\]/.test(css) ? "" : css;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/layout.ts
|
|
60
|
+
const el = (tag, attrs = {}, children = []) => ({
|
|
61
|
+
tag,
|
|
62
|
+
attrs,
|
|
63
|
+
children
|
|
64
|
+
});
|
|
65
|
+
const ATTRIBUTION_URL = "https://submitium.com/?ref=badge-wall";
|
|
66
|
+
/** Badge height in every format. */
|
|
67
|
+
const H = 40;
|
|
68
|
+
const MAX_RATIO = 6;
|
|
69
|
+
/** Ratio assumed when the official size is unknown: the image letterboxes inside its box. */
|
|
70
|
+
const FALLBACK_RATIO = 3.7;
|
|
71
|
+
/** A moving window narrower than this shows its badges still instead. */
|
|
72
|
+
const MIN_WINDOW = 200;
|
|
73
|
+
const CSS_BASE = `.sbm{display:block;margin:0;padding:0;font:inherit;color:inherit;text-align:left}.sbm *{box-sizing:border-box}.sbm ul,.sbm li{margin:0;padding:0;list-style:none}.sbm a{color:inherit;border:0;background:none;box-shadow:none}.sbm img{display:block;margin:0;padding:0;border:0;border-radius:0;box-shadow:none;background:none;object-fit:contain}.sbm picture{display:contents}.sbm a:focus-visible{outline:2px solid currentColor;outline-offset:2px}.sbm-lb{flex:none;opacity:.7;white-space:nowrap}.sbm-fix{display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px;min-width:0}.sbm-bar+.sbm-fix{margin-top:12px}.sbm-i{display:flex;align-items:center;min-height:${H}px;max-width:100%}.sbm-ctl{display:flex;align-items:center;gap:14px;margin-left:auto}.sbm:hover .sbm-it,.sbm:focus-within .sbm-it{animation-play-state:paused}`;
|
|
74
|
+
const CSS_BY = ".sbm-by{display:inline-flex;align-items:center;gap:6px;font-size:12px;line-height:16px;text-decoration:none;white-space:nowrap;opacity:.75}.sbm-by:hover{opacity:1}.sbm-by b{font-weight:600}.sbm-m{display:inline-grid;place-items:center;width:16px;height:16px;border-radius:4px;background:#fff;box-shadow:0 0 0 1px color-mix(in srgb,currentColor 18%,transparent)}.sbm-m svg{display:block;width:12px;height:12px}";
|
|
75
|
+
/** Edges fade where badges come and go (the badges still never fade at rest). */
|
|
76
|
+
function mask(edge, axis = "x") {
|
|
77
|
+
const g = `linear-gradient(${axis === "x" ? "90deg" : "180deg"},transparent,#000 ${edge}px,#000 calc(100% - ${edge}px),transparent)`;
|
|
78
|
+
return `-webkit-mask-image:${g};mask-image:${g}`;
|
|
79
|
+
}
|
|
80
|
+
const NO_MASK = "-webkit-mask-image:none;mask-image:none";
|
|
81
|
+
/** Tiny stable hash, so identical loops share one @keyframes name. */
|
|
82
|
+
function hash(value) {
|
|
83
|
+
let h = 5381;
|
|
84
|
+
for (let i = 0; i < value.length; i++) h = (h * 33 ^ value.charCodeAt(i)) >>> 0;
|
|
85
|
+
return h.toString(36);
|
|
86
|
+
}
|
|
87
|
+
const seconds = (s) => `${+s.toFixed(2)}s`;
|
|
88
|
+
/**
|
|
89
|
+
* Continuous loop without clones, for badges of different widths: each badge travels from
|
|
90
|
+
* (L - G) to -G at the same speed and wraps while out of view (L = whole row, G = widest badge
|
|
91
|
+
* + gap). The window must stay narrower than L - G, returned as `maxWindow`.
|
|
92
|
+
*/
|
|
93
|
+
function glideLoop(widths, gap, speed) {
|
|
94
|
+
const L = widths.reduce((sum, w) => sum + w + gap, 0);
|
|
95
|
+
const G = Math.max(...widths) + gap;
|
|
96
|
+
const frames = `{from{transform:translateX(${L - G}px)}to{transform:translateX(${-G}px)}}`;
|
|
97
|
+
const name = `sbm-g${hash(frames)}`;
|
|
98
|
+
let x = 0;
|
|
99
|
+
const delays = widths.map((w) => {
|
|
100
|
+
const elapsed = ((L - G - x) % L + L) % L;
|
|
101
|
+
x += w + gap;
|
|
102
|
+
return seconds(-elapsed / speed);
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
css: `@keyframes ${name}${frames}`,
|
|
106
|
+
name,
|
|
107
|
+
duration: seconds(L / speed),
|
|
108
|
+
delays,
|
|
109
|
+
maxWindow: L - G
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Width of a badge at its display height. */
|
|
113
|
+
function widthOf(b) {
|
|
114
|
+
const { width: w, height: h } = b.image;
|
|
115
|
+
return Math.round((h ? Math.min(h, H) : H) * Math.min(MAX_RATIO, w && h ? w / h : FALLBACK_RATIO));
|
|
116
|
+
}
|
|
117
|
+
const heightOf = (b) => b.image.height ? Math.min(b.image.height, H) : H;
|
|
118
|
+
/** The rel a badge link carries: the directory's own, and noopener for the new tab. */
|
|
119
|
+
const relOf = (b) => [.../* @__PURE__ */ new Set([...b.rel, "noopener"])].join(" ");
|
|
120
|
+
/** One badge: the directory's link around its image, at its display size. */
|
|
121
|
+
function badgeNode(b, theme, inZone) {
|
|
122
|
+
const dark = b.image.srcDark;
|
|
123
|
+
const img = el("img", {
|
|
124
|
+
src: theme === "dark" && dark ? dark : b.image.src,
|
|
125
|
+
alt: b.image.alt,
|
|
126
|
+
...b.image.width ? { width: b.image.width } : {},
|
|
127
|
+
...b.image.height ? { height: b.image.height } : {},
|
|
128
|
+
loading: "lazy",
|
|
129
|
+
decoding: "async",
|
|
130
|
+
style: {
|
|
131
|
+
width: `${widthOf(b)}px`,
|
|
132
|
+
height: `${heightOf(b)}px`,
|
|
133
|
+
"max-width": inZone ? "none" : "100%",
|
|
134
|
+
"object-position": "left center"
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const picture = theme === "auto" && dark ? el("picture", {}, [el("source", {
|
|
138
|
+
media: "(prefers-color-scheme: dark)",
|
|
139
|
+
srcset: dark.replaceAll(" ", "%20").replaceAll(",", "%2C")
|
|
140
|
+
}), img]) : img;
|
|
141
|
+
return el("a", {
|
|
142
|
+
href: b.href,
|
|
143
|
+
target: "_blank",
|
|
144
|
+
rel: relOf(b)
|
|
145
|
+
}, [picture]);
|
|
146
|
+
}
|
|
147
|
+
const zone = (cls, items) => el("div", {
|
|
148
|
+
class: cls,
|
|
149
|
+
"data-submitium-zone": "moving"
|
|
150
|
+
}, [el("ul", { class: "sbm-t" }, items)]);
|
|
151
|
+
const item = (delay, child) => el("li", {
|
|
152
|
+
class: "sbm-it",
|
|
153
|
+
style: { "animation-delay": delay }
|
|
154
|
+
}, [child]);
|
|
155
|
+
/** 1 · Ribbon: one line in the footer bar, every badge gliding slowly. */
|
|
156
|
+
function ribbon({ movers, badge }) {
|
|
157
|
+
const gap = 28;
|
|
158
|
+
const loop = glideLoop(movers.map((m) => m.width), gap, 26);
|
|
159
|
+
if (movers.length < 2 || loop.maxWindow < MIN_WINDOW) return {
|
|
160
|
+
css: "",
|
|
161
|
+
moving: null
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
css: `.sbm-w{position:relative;flex:1 1 260px;min-width:0;max-width:${Math.min(760, loop.maxWindow)}px;height:${H}px;overflow:hidden;${mask(56)}}.sbm-it{position:absolute;top:0;left:0;height:${H}px;display:flex;align-items:center;animation:${loop.name} ${loop.duration} linear infinite;will-change:transform}` + loop.css + `@media (prefers-reduced-motion:reduce){.sbm-w{overflow-x:auto;${NO_MASK}}.sbm-t{display:flex;gap:${gap}px;width:max-content}.sbm-it{position:static;animation:none}}`,
|
|
165
|
+
moving: zone("sbm-w", movers.map((m, i) => item(loop.delays[i], badge(m))))
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** 2 · Window: a small gliding window, under the logo and tagline. */
|
|
169
|
+
function windowFormat({ movers, badge }) {
|
|
170
|
+
const gap = 22;
|
|
171
|
+
const loop = glideLoop(movers.map((m) => m.width), gap, 22);
|
|
172
|
+
if (movers.length < 2 || loop.maxWindow < MIN_WINDOW) return {
|
|
173
|
+
css: "",
|
|
174
|
+
moving: null
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
css: `.sbm-w{position:relative;width:${Math.min(320, loop.maxWindow)}px;max-width:100%;height:${H}px;overflow:hidden;${mask(36)}}.sbm-it{position:absolute;top:0;left:0;height:${H}px;display:flex;align-items:center;animation:${loop.name} ${loop.duration} linear infinite;will-change:transform}` + loop.css + `@media (prefers-reduced-motion:reduce){.sbm-w{overflow-x:auto;${NO_MASK}}.sbm-t{display:flex;gap:${gap}px;width:max-content}.sbm-it{position:static;animation:none}}`,
|
|
178
|
+
moving: zone("sbm-w", movers.map((m, i) => item(loop.delays[i], badge(m))))
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const LAYOUT_CSS = {
|
|
182
|
+
ribbon: ".sbm-ribbon{container-type:inline-size}.sbm-bar{display:flex;align-items:center;flex-wrap:wrap;gap:10px 16px;font-size:12px}@container (max-width:520px){.sbm-lb{display:none}.sbm-w{flex-basis:100%}}",
|
|
183
|
+
window: ".sbm-col{display:flex;flex-direction:column;align-items:flex-start;gap:10px;font-size:12px}"
|
|
184
|
+
};
|
|
185
|
+
const MARK_PATHS = [["#4432F4", "M20 1l-1 1 0 2 6 27 1 1 2 0 1 0 6-12 12-2 1-2-1-1-11-6-14-8-2 0zM39 22l-2 0-7 11-2 1-2 0-2-1-3-11-14 8-1 1 2 2 20 10 3 1 2 0 23-11 2-2-1-1-15-9-3 1z"], ["#CAC9FD", "M11 37l-5 3 0 1 1 0 25 13 2-1 18-9 5-2 1-2-1-1-5-2-19 9-1 1-21-10zM10 47l-4 1 0 2 1 1 25 12 2 0 9-5 13-7 2-1 0-2-2 0-4-2-17 9-3 1-20-10-2 1z"]];
|
|
186
|
+
function attributionNode(rel) {
|
|
187
|
+
const mark = el("svg", {
|
|
188
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
189
|
+
viewBox: "0 0 64 64",
|
|
190
|
+
width: 12,
|
|
191
|
+
height: 12,
|
|
192
|
+
"aria-hidden": "true"
|
|
193
|
+
}, MARK_PATHS.map(([fill, d]) => el("path", {
|
|
194
|
+
fill,
|
|
195
|
+
d
|
|
196
|
+
})));
|
|
197
|
+
return el("a", {
|
|
198
|
+
class: "sbm-by",
|
|
199
|
+
href: ATTRIBUTION_URL,
|
|
200
|
+
target: "_blank",
|
|
201
|
+
rel: rel === "nofollow" ? "nofollow noopener" : "noopener"
|
|
202
|
+
}, [el("span", { class: "sbm-m" }, [mark]), el("span", {}, ["Badge wall by ", el("b", {}, ["Submitium"])])]);
|
|
203
|
+
}
|
|
204
|
+
/** Splits on the commas outside parentheses: `a,b:is(c,d)` is two selectors. */
|
|
205
|
+
function selectors(head) {
|
|
206
|
+
const out = [];
|
|
207
|
+
let depth = 0;
|
|
208
|
+
let start = 0;
|
|
209
|
+
for (let i = 0; i < head.length; i++) if (head[i] === "(") depth++;
|
|
210
|
+
else if (head[i] === ")") depth--;
|
|
211
|
+
else if (head[i] === "," && depth === 0) {
|
|
212
|
+
out.push(head.slice(start, i).trim());
|
|
213
|
+
start = i + 1;
|
|
214
|
+
}
|
|
215
|
+
out.push(head.slice(start).trim());
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Every rule limited to this wall (its root carries `scope`): two walls on one page, a footer and
|
|
220
|
+
* an "As seen on" section, never restyle each other. A selector that starts on one of the root's
|
|
221
|
+
* own classes (`.sbm`, `.sbm-ribbon`) is the root itself; keyframes are named by their content and
|
|
222
|
+
* shared as they are.
|
|
223
|
+
*/
|
|
224
|
+
function scoped(css, scope, root) {
|
|
225
|
+
let out = "";
|
|
226
|
+
let i = 0;
|
|
227
|
+
while (i < css.length) {
|
|
228
|
+
const open = css.indexOf("{", i);
|
|
229
|
+
if (open < 0) break;
|
|
230
|
+
let depth = 1;
|
|
231
|
+
let j = open + 1;
|
|
232
|
+
while (j < css.length && depth) {
|
|
233
|
+
if (css[j] === "{") depth++;
|
|
234
|
+
else if (css[j] === "}") depth--;
|
|
235
|
+
j++;
|
|
236
|
+
}
|
|
237
|
+
const head = css.slice(i, open).trim();
|
|
238
|
+
const inner = css.slice(open + 1, j - 1);
|
|
239
|
+
if (head.startsWith("@keyframes")) out += `${head}{${inner}}`;
|
|
240
|
+
else if (head.startsWith("@")) out += `${head}{${scoped(inner, scope, root)}}`;
|
|
241
|
+
else {
|
|
242
|
+
const prefixed = selectors(head).map((sel) => {
|
|
243
|
+
const own = /^\.([\w-]+)/.exec(sel);
|
|
244
|
+
return own && root.has(own[1]) ? `${scope}${sel}` : `${scope} ${sel}`;
|
|
245
|
+
});
|
|
246
|
+
out += `${prefixed.join(",")}{${inner}}`;
|
|
247
|
+
}
|
|
248
|
+
i = j;
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The wall as a tree. An empty wall (nothing ordered yet) is its root alone: invisible, and still
|
|
254
|
+
* found by our check, which looks for the project's key on it.
|
|
255
|
+
*/
|
|
256
|
+
function layoutWall(wall) {
|
|
257
|
+
const { variant, theme, attribution } = wall.settings;
|
|
258
|
+
const classes = [
|
|
259
|
+
"sbm",
|
|
260
|
+
`sbm-${variant}`,
|
|
261
|
+
`sbm-${theme}`
|
|
262
|
+
];
|
|
263
|
+
let scope = null;
|
|
264
|
+
const root = (children) => el("div", {
|
|
265
|
+
class: [...classes, ...scope ? [scope] : []].join(" "),
|
|
266
|
+
"data-submitium": wall.key,
|
|
267
|
+
"data-submitium-variant": variant
|
|
268
|
+
}, children);
|
|
269
|
+
if (wall.badges.length === 0) return root([]);
|
|
270
|
+
const candidates = wall.badges.filter((b) => !b.still).map((b) => ({
|
|
271
|
+
badge: b,
|
|
272
|
+
width: widthOf(b)
|
|
273
|
+
}));
|
|
274
|
+
const parts = {
|
|
275
|
+
movers: candidates,
|
|
276
|
+
badge: (m) => badgeNode(m.badge, theme, true)
|
|
277
|
+
};
|
|
278
|
+
const built = variant === "window" ? windowFormat(parts) : ribbon(parts);
|
|
279
|
+
const moving = new Set(built.moving ? candidates.map((m) => m.badge) : []);
|
|
280
|
+
const still = wall.badges.filter((b) => !moving.has(b));
|
|
281
|
+
const fixed = still.length ? el("div", {
|
|
282
|
+
class: "sbm-fix",
|
|
283
|
+
"data-submitium-zone": "fixed"
|
|
284
|
+
}, still.map((b) => el("span", { class: "sbm-i" }, [badgeNode(b, theme, false)]))) : null;
|
|
285
|
+
const by = attribution === "none" ? null : attributionNode(attribution);
|
|
286
|
+
const ctl = by ? el("span", { class: "sbm-ctl" }, [by]) : null;
|
|
287
|
+
const zoneNode = built.moving;
|
|
288
|
+
const label = el("span", { class: "sbm-lb" }, ["Featured on"]);
|
|
289
|
+
const rules = CSS_BASE + (by ? CSS_BY : "") + LAYOUT_CSS[variant] + built.css;
|
|
290
|
+
scope = `sbm-x${hash(rules)}`;
|
|
291
|
+
const css = el("style", {}, [scoped(rules, `.${scope}`, new Set(classes))]);
|
|
292
|
+
const some = (...nodes) => nodes.filter((n) => n !== null);
|
|
293
|
+
if (variant === "window") return root([css, el("div", { class: "sbm-col" }, some(zoneNode, fixed, by))]);
|
|
294
|
+
return zoneNode ? root(some(css, el("div", { class: "sbm-bar" }, some(label, zoneNode, ctl)), fixed)) : root([css, el("div", { class: "sbm-bar" }, some(label, fixed, ctl))]);
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
297
|
+
export { attrsOf as a, TAGS as i, layoutWall as n, cssOf as o, ATTRS as r, styleText as s, ATTRIBUTION_URL as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@submitium/badges",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The Submitium badge wall: a signed, checked wall rendered as plain links and images. React, Vue, Svelte, Astro.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Submitium <contact@submitium.com>",
|
|
7
|
+
"homepage": "https://submitium.com/badge-wall",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"email": "contact@submitium.com"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./index.d.ts",
|
|
16
|
+
"import": "./index.js"
|
|
17
|
+
},
|
|
18
|
+
"./react": {
|
|
19
|
+
"types": "./react.d.ts",
|
|
20
|
+
"import": "./react.js"
|
|
21
|
+
},
|
|
22
|
+
"./vue": {
|
|
23
|
+
"types": "./vue.d.ts",
|
|
24
|
+
"import": "./vue.js"
|
|
25
|
+
},
|
|
26
|
+
"./svelte": {
|
|
27
|
+
"svelte": "./svelte/BadgeWall.svelte",
|
|
28
|
+
"default": "./svelte/BadgeWall.svelte"
|
|
29
|
+
},
|
|
30
|
+
"./astro": "./astro/SubmitiumBadges.astro"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": ">=18.3",
|
|
34
|
+
"vue": ">=3.4",
|
|
35
|
+
"svelte": ">=5",
|
|
36
|
+
"astro": ">=4"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"badges",
|
|
40
|
+
"directories",
|
|
41
|
+
"react-server-components",
|
|
42
|
+
"nextjs",
|
|
43
|
+
"nuxt",
|
|
44
|
+
"sveltekit",
|
|
45
|
+
"astro"
|
|
46
|
+
],
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"react": {
|
|
49
|
+
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"vue": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"svelte": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"astro": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=20"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/react.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { d as Wall } from "./contract-KBbC2LBR.js";
|
|
2
|
+
import { n as FetchWallOptions } from "./client-iCo9PkAU.js";
|
|
3
|
+
import { ReactNode } from "react";
|
|
4
|
+
//#region src/react.d.ts
|
|
5
|
+
/** A wall you already fetched (with `fetchWall`), rendered. */
|
|
6
|
+
export declare function BadgeWall({ wall, className }: {
|
|
7
|
+
wall: Wall;
|
|
8
|
+
className?: string;
|
|
9
|
+
}): ReactNode;
|
|
10
|
+
export type SubmitiumBadgesProps = FetchWallOptions & {
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The badge wall, fetched and rendered on your server. Put it on your homepage, usually in the
|
|
15
|
+
* footer. Renders nothing if the API is slow, down or the wall doesn't check out.
|
|
16
|
+
*/
|
|
17
|
+
export declare function SubmitiumBadges({ className, ...options }: SubmitiumBadgesProps): Promise<import("react").FunctionComponentElement<{
|
|
18
|
+
wall: Wall;
|
|
19
|
+
className?: string;
|
|
20
|
+
}> | null>;
|
|
21
|
+
//#endregion
|
package/react.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { n as fetchWall } from "./client-x4bD7iXW.js";
|
|
2
|
+
import { a as attrsOf, i as TAGS, n as layoutWall, o as cssOf } from "./layout-DvNYYz0J.js";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
//#region src/react.tsx
|
|
5
|
+
/** React's names for the attributes React spells differently. */
|
|
6
|
+
const REACT_NAMES = {
|
|
7
|
+
class: "className",
|
|
8
|
+
srcset: "srcSet"
|
|
9
|
+
};
|
|
10
|
+
function toReact(node, key) {
|
|
11
|
+
if (!TAGS.has(node.tag)) return null;
|
|
12
|
+
const props = key === void 0 ? {} : { key };
|
|
13
|
+
for (const [name, value] of attrsOf(node)) if (name === "style") props.style = styleObject(node.attrs.style ?? {});
|
|
14
|
+
else props[REACT_NAMES[name] ?? name] = value;
|
|
15
|
+
if (node.tag === "style") return createElement("style", props, cssOf(node));
|
|
16
|
+
const children = node.children.map((c, i) => typeof c === "string" ? c : toReact(c, i));
|
|
17
|
+
return createElement(node.tag, props, ...children);
|
|
18
|
+
}
|
|
19
|
+
const styleObject = (style) => Object.fromEntries(Object.entries(style).map(([k, v]) => [k.replace(/-([a-z])/g, (_, c) => c.toUpperCase()), v]));
|
|
20
|
+
/** A wall you already fetched (with `fetchWall`), rendered. */
|
|
21
|
+
function BadgeWall({ wall, className }) {
|
|
22
|
+
const tree = layoutWall(wall);
|
|
23
|
+
return className ? createElement("div", { className }, toReact(tree)) : toReact(tree);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The badge wall, fetched and rendered on your server. Put it on your homepage, usually in the
|
|
27
|
+
* footer. Renders nothing if the API is slow, down or the wall doesn't check out.
|
|
28
|
+
*/
|
|
29
|
+
async function SubmitiumBadges({ className, ...options }) {
|
|
30
|
+
const wall = await fetchWall(options);
|
|
31
|
+
return wall ? createElement(BadgeWall, {
|
|
32
|
+
wall,
|
|
33
|
+
className
|
|
34
|
+
}) : null;
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { BadgeWall, SubmitiumBadges };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// SvelteKit: fetch the wall in a server load (`+layout.server.ts`, with `fetchWall` and your API
|
|
3
|
+
// key from `$env/static/private`), then render it here. The badges are in the HTML the server sends.
|
|
4
|
+
import { layoutWall, type Wall } from "../index.js";
|
|
5
|
+
import Node from "./Node.svelte";
|
|
6
|
+
|
|
7
|
+
let { wall }: { wall: Wall } = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<Node node={layoutWall(wall)} />
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// One element of the wall's tree, written by Svelte: only the elements and attributes the tree
|
|
3
|
+
// allows (TAGS, attrsOf), every value escaped. No HTML string is ever inserted.
|
|
4
|
+
import { attrsOf, cssOf, TAGS, type WallNode } from "../index.js";
|
|
5
|
+
import Node from "./Node.svelte";
|
|
6
|
+
|
|
7
|
+
let { node }: { node: WallNode } = $props();
|
|
8
|
+
const attrs = Object.fromEntries(attrsOf(node));
|
|
9
|
+
const empty = ["img", "source", "input", "path"].includes(node.tag);
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
{#if TAGS.has(node.tag)}
|
|
13
|
+
{#if node.tag === "style"}
|
|
14
|
+
<svelte:element this="style">{cssOf(node)}</svelte:element>
|
|
15
|
+
{:else if empty}
|
|
16
|
+
<svelte:element this={node.tag} {...attrs} />
|
|
17
|
+
{:else}
|
|
18
|
+
<svelte:element this={node.tag} {...attrs}
|
|
19
|
+
>{#each node.children as child}{#if typeof child === "string"}{child}{:else}<Node node={child} />{/if}{/each}</svelte:element
|
|
20
|
+
>
|
|
21
|
+
{/if}
|
|
22
|
+
{/if}
|
package/vue.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { d as Wall } from "./contract-KBbC2LBR.js";
|
|
2
|
+
import { PropType, VNode } from "vue";
|
|
3
|
+
//#region src/vue.d.ts
|
|
4
|
+
export declare const BadgeWall: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
5
|
+
wall: {
|
|
6
|
+
type: PropType<Wall>;
|
|
7
|
+
required: true;
|
|
8
|
+
};
|
|
9
|
+
}>, () => VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
10
|
+
[key: string]: any;
|
|
11
|
+
}> | null, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
12
|
+
wall: {
|
|
13
|
+
type: PropType<Wall>;
|
|
14
|
+
required: true;
|
|
15
|
+
};
|
|
16
|
+
}>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { BadgeWall as default };
|
package/vue.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { a as attrsOf, i as TAGS, n as layoutWall, o as cssOf } from "./layout-DvNYYz0J.js";
|
|
2
|
+
import { defineComponent, h } from "vue";
|
|
3
|
+
//#region src/vue.ts
|
|
4
|
+
function toVue(node) {
|
|
5
|
+
if (!TAGS.has(node.tag)) return null;
|
|
6
|
+
const props = {};
|
|
7
|
+
for (const [name, value] of attrsOf(node)) props[name] = name === "style" ? node.attrs.style ?? {} : value;
|
|
8
|
+
if (node.tag === "style") return h("style", props, cssOf(node));
|
|
9
|
+
return h(node.tag, props, node.children.map((c) => typeof c === "string" ? c : toVue(c)));
|
|
10
|
+
}
|
|
11
|
+
const BadgeWall = defineComponent({
|
|
12
|
+
name: "SubmitiumBadgeWall",
|
|
13
|
+
props: { wall: {
|
|
14
|
+
type: Object,
|
|
15
|
+
required: true
|
|
16
|
+
} },
|
|
17
|
+
setup(props) {
|
|
18
|
+
return () => toVue(layoutWall(props.wall));
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
//#endregion
|
|
22
|
+
export { BadgeWall, BadgeWall as default };
|