@profullstack/footer 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Profullstack, Inc.
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,104 @@
1
- # Temporary Holding Version
1
+ # @profullstack/footer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One footer for every Profullstack site: your links, the copyright line, and the
4
+ `<< Profullstack >> ⚄` webring nav (previous, ring, next, random).
5
+
6
+ **Publish once, every site updates.** The markup and CSS ship as data
7
+ (`template.json`). At render time each site fetches
8
+ `https://cdn.jsdelivr.net/npm/@profullstack/footer@latest/template.json`
9
+ (1 h cache, 1.5 s timeout, the bundled copy as the fallback), so a release reaches
10
+ every site within the hour with no bump, rebuild or redeploy. Install it as
11
+ `"@profullstack/footer": "latest"` too, so the render code follows on the next install.
12
+
13
+ It renders on the server: the ring's verifier reads your HTML and never runs
14
+ script, so a client-only footer would never count as a ring member.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ pnpm add @profullstack/footer@latest # or npm i / bun add
20
+ ```
21
+
22
+ ## Next.js (App Router)
23
+
24
+ ```jsx
25
+ // app/layout.jsx (a server component)
26
+ import { Footer } from '@profullstack/footer/react';
27
+
28
+ <Footer site="https://example.com/" links={[{ label: 'Docs', href: '/docs' }]} since={2025} />
29
+ ```
30
+
31
+ and the ring descriptor:
32
+
33
+ ```js
34
+ // app/.well-known/openwebring.json/route.js
35
+ import { descriptorResponse } from '@profullstack/footer';
36
+ export const GET = () => descriptorResponse({ site: 'https://example.com/', name: 'Example' });
37
+ ```
38
+
39
+ ## Hono / Express / Bun / SvelteKit
40
+
41
+ ```js
42
+ import { footerHtml } from '@profullstack/footer';
43
+ const footer = await footerHtml({ site: 'https://example.com/', links: [{ label: 'Docs', href: '/docs' }] });
44
+ // Svelte: {@html footer} Hono: c.html(`...${footer}`)
45
+ ```
46
+
47
+ ## Static HTML (no server)
48
+
49
+ Print the footer once and keep it in the page (that is what the verifier sees),
50
+ then let the embed swap in the @latest one in the browser:
51
+
52
+ ```sh
53
+ npx @profullstack/footer html --site https://example.com/ --link Docs=/docs
54
+ ```
55
+
56
+ ```html
57
+ <footer data-profullstack-footer data-site="https://example.com/" data-links='[{"label":"Docs","href":"/docs"}]'>
58
+ …output of the command above…
59
+ </footer>
60
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@profullstack/footer@latest/src/embed.js"></script>
61
+ ```
62
+
63
+ ## Options
64
+
65
+ | option | default | |
66
+ |---|---|---|
67
+ | `site` | required | your homepage; `from=` on every hop |
68
+ | `links` | `[]` | `{ label, href, rel? }[]`, shown before house links |
69
+ | `since` | | first year: `© 2024–2026` |
70
+ | `owner` | `Profullstack, Inc.` → profullstack.com | `{ name, url }` |
71
+ | `tagline` | | after the copyright, e.g. `MIT licensed` |
72
+ | `ring` | `{ slug: 'profullstack', name: 'Profullstack', host: 'https://rssamplifier.com' }` | |
73
+ | `css` | `true` | `false` drops the `<style>`; style `.pfs-footer*` yourself |
74
+ | `houseLinks` | `true` | `false` hides links the template adds for every site |
75
+
76
+ Theme it with `--pfs-footer-muted`, `--pfs-footer-gap` and `--pfs-footer-size` on
77
+ `.pfs-footer`; colors otherwise inherit from the page.
78
+
79
+ ## CLI
80
+
81
+ ```sh
82
+ profullstack-footer html --site https://example.com/ [--link Docs=/docs] [--since 2024] [--offline] [--no-css]
83
+ profullstack-footer json --site https://example.com/ # the four ring URLs
84
+ profullstack-footer descriptor --site https://example.com/ # /.well-known/openwebring.json
85
+ profullstack-footer template # the @latest template
86
+ profullstack-footer check https://example.com/ # does the live page link the ring?
87
+ ```
88
+
89
+ ## Changing the footer for every site
90
+
91
+ Edit `src/template.js`, bump the version in it and in package.json, `npm run build`,
92
+ open a PR, merge, and publish a GitHub release `vX.Y.Z`. The publish workflow puts it
93
+ on npm and purges jsDelivr's `@latest` so sites pick it up on their next cache refresh.
94
+ Bump `renderer` (and `RENDERER` in render.js) only when the template needs a
95
+ placeholder older installs cannot fill; they keep their bundled template until
96
+ reinstalled.
97
+
98
+ **Safety:** remote templates are sanitized before use (no `<script>`, `<iframe>`,
99
+ event handlers or `javascript:` URLs; CSS cannot close its `<style>` or `@import`),
100
+ and option values are always escaped.
101
+
102
+ Env: `PROFULLSTACK_FOOTER_OFFLINE=1` never fetches; `PROFULLSTACK_FOOTER_TEMPLATE_URL` overrides the CDN URL.
103
+
104
+ MIT © Profullstack, Inc.
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // profullstack-footer: print the footer or the ring descriptor for a site.
3
+ //
4
+ // profullstack-footer html --site https://example.com/ [--name Example] [--link Docs=/docs]... [--since 2024] [--no-css]
5
+ // profullstack-footer json --site https://example.com/ the ring URLs
6
+ // profullstack-footer descriptor --site https://example.com/ [--slug example-com] [--name Example]
7
+ // profullstack-footer template the @latest template this install would use
8
+ // profullstack-footer check https://example.com/ does the live page carry prev/next/random?
9
+
10
+ import { parseArgs } from 'node:util';
11
+ import { footerHtml, footerHtmlSync, ringLinks, descriptor, latestTemplate, canonicalSite } from '../src/index.js';
12
+
13
+ const HELP = `usage: profullstack-footer <html|json|descriptor|template|check> [options]
14
+
15
+ --site <url> the site's homepage (required except for template)
16
+ --name <name> site name (descriptor, data attribute)
17
+ --link Label=href a footer link; repeat for more
18
+ --since <year> first copyright year
19
+ --tagline <text> short line after the copyright
20
+ --slug <slug> ring member slug (descriptor; default host with dashes)
21
+ --offline use the bundled template, do not fetch @latest
22
+ --no-css leave the <style> out
23
+ `;
24
+
25
+ const { values, positionals } = parseArgs({
26
+ allowPositionals: true,
27
+ options: {
28
+ site: { type: 'string' },
29
+ name: { type: 'string' },
30
+ link: { type: 'string', multiple: true },
31
+ since: { type: 'string' },
32
+ tagline: { type: 'string' },
33
+ slug: { type: 'string' },
34
+ offline: { type: 'boolean' },
35
+ 'no-css': { type: 'boolean' },
36
+ help: { type: 'boolean', short: 'h' },
37
+ },
38
+ });
39
+
40
+ const [cmd, arg] = positionals;
41
+ if (values.help || !cmd) {
42
+ process.stdout.write(HELP);
43
+ process.exit(cmd || values.help ? 0 : 2);
44
+ }
45
+
46
+ const site = values.site ?? arg;
47
+ const need = () => {
48
+ if (!site) {
49
+ process.stderr.write('profullstack-footer: --site is required\n');
50
+ process.exit(2);
51
+ }
52
+ return site;
53
+ };
54
+ const links = (values.link ?? []).map((s) => {
55
+ const i = s.indexOf('=');
56
+ return { label: s.slice(0, i), href: s.slice(i + 1) };
57
+ });
58
+ const opts = () => ({
59
+ site: need(),
60
+ name: values.name,
61
+ links,
62
+ since: values.since ? Number(values.since) : undefined,
63
+ tagline: values.tagline,
64
+ css: !values['no-css'],
65
+ });
66
+
67
+ switch (cmd) {
68
+ case 'html':
69
+ process.stdout.write((values.offline ? footerHtmlSync(opts()) : await footerHtml(opts())) + '\n');
70
+ break;
71
+ case 'json':
72
+ process.stdout.write(JSON.stringify(ringLinks(need()), null, 2) + '\n');
73
+ break;
74
+ case 'descriptor':
75
+ process.stdout.write(JSON.stringify(descriptor({ site: need(), name: values.name, slug: values.slug }), null, 2) + '\n');
76
+ break;
77
+ case 'template':
78
+ process.stdout.write(JSON.stringify(await latestTemplate(), null, 2) + '\n');
79
+ break;
80
+ case 'check': {
81
+ const home = canonicalSite(need());
82
+ const res = await fetch(home, { redirect: 'follow', headers: { 'user-agent': 'profullstack-footer-check' } });
83
+ const html = (await res.text()).replace(/&amp;/g, '&');
84
+ const want = ringLinks(home);
85
+ let ok = res.ok;
86
+ for (const k of /** @type {const} */ (['previous', 'next', 'random'])) {
87
+ const has = html.includes(want[k]);
88
+ ok &&= has;
89
+ process.stdout.write(`${has ? 'ok ' : 'MISS'} ${k} ${want[k]}\n`);
90
+ }
91
+ process.stdout.write(`${home} ${res.status} ${ok ? 'links the ring' : 'does NOT link the ring'}\n`);
92
+ process.exit(ok ? 0 : 1);
93
+ }
94
+ default:
95
+ process.stderr.write(`profullstack-footer: unknown command ${cmd}\n${HELP}`);
96
+ process.exit(2);
97
+ }
package/index.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ export interface FooterLink { label: string; href: string; rel?: string }
2
+ export interface RingOptions { slug?: string; name?: string; host?: string }
3
+ export interface FooterOptions {
4
+ /** The site's homepage; hops are measured from it. */
5
+ site: string;
6
+ name?: string;
7
+ /** The site's own links, shown before any house links. */
8
+ links?: FooterLink[];
9
+ /** Defaults to Profullstack, Inc. / https://profullstack.com/ */
10
+ owner?: { name?: string; url?: string };
11
+ /** First copyright year: renders "since–year". */
12
+ since?: number;
13
+ /** Override the copyright year (defaults to the current UTC year). */
14
+ year?: number;
15
+ tagline?: string;
16
+ ring?: RingOptions;
17
+ /** false leaves the <style> out (bring your own CSS). */
18
+ css?: boolean;
19
+ /** false hides the template's house links. */
20
+ houseLinks?: boolean;
21
+ }
22
+ export interface FooterTemplate {
23
+ version: string;
24
+ renderer: number;
25
+ css: string;
26
+ link: string;
27
+ footer: string;
28
+ houseLinks?: FooterLink[];
29
+ }
30
+ export interface RingLinks { ring: string; previous: string; next: string; random: string }
31
+ export interface Descriptor {
32
+ openwebring: '0.1';
33
+ site: { url: string; name: string };
34
+ made_by: 'human' | 'ai' | 'both';
35
+ rings: { ring: string; slug: string }[];
36
+ }
37
+ export interface DescriptorOptions {
38
+ site: string;
39
+ name?: string;
40
+ slug?: string;
41
+ madeBy?: 'human' | 'ai' | 'both';
42
+ ring?: RingOptions;
43
+ }
44
+
45
+ export declare const RENDERER: number;
46
+ export declare const TEMPLATE_URL: string;
47
+ export declare const DEFAULT_RING: Required<RingOptions>;
48
+ export declare const DEFAULT_OWNER: { name: string; url: string };
49
+ export declare const bundledTemplate: FooterTemplate;
50
+
51
+ export declare function footerHtml(opts: FooterOptions & { template?: FooterTemplate }): Promise<string>;
52
+ export declare function footerHtmlSync(opts: FooterOptions): string;
53
+ export declare function renderFooter(template: FooterTemplate, opts: FooterOptions): string;
54
+ export declare function latestTemplate(opts?: {
55
+ url?: string;
56
+ ttlMs?: number;
57
+ timeoutMs?: number;
58
+ fetch?: typeof fetch;
59
+ now?: () => number;
60
+ }): Promise<FooterTemplate>;
61
+ export declare function resetTemplateCache(): void;
62
+ export declare function ringLinks(site: string, ring?: RingOptions): RingLinks;
63
+ export declare function canonicalSite(site: string): string;
64
+ export declare function descriptor(opts: DescriptorOptions): Descriptor;
65
+ export declare function descriptorResponse(opts: DescriptorOptions): Response;
66
+ export declare function escapeHtml(value: unknown): string;
67
+ export declare function sanitizeTemplate(html: string): string;
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@profullstack/footer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "One footer for every Profullstack site: links, copyright and the << Profullstack >> webring nav with Random. Sites render the @latest template from the CDN at runtime, so a release updates every site without a redeploy.",
6
+ "keywords": ["footer", "webring", "openwebring", "copyright", "react", "nextjs", "hono", "ssr"],
7
+ "repository": { "type": "git", "url": "git+https://github.com/profullstack/footer.git" },
8
+ "homepage": "https://github.com/profullstack/footer#readme",
9
+ "bugs": { "url": "https://github.com/profullstack/footer/issues" },
10
+ "license": "MIT",
11
+ "author": "Profullstack, Inc.",
12
+ "exports": {
13
+ ".": { "types": "./index.d.ts", "import": "./src/index.js" },
14
+ "./react": { "types": "./react.d.ts", "import": "./src/react.js" },
15
+ "./render": { "types": "./index.d.ts", "import": "./src/render.js" },
16
+ "./embed": "./src/embed.js",
17
+ "./template.json": "./template.json",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "bin": { "profullstack-footer": "bin/profullstack-footer.js" },
21
+ "files": ["src", "bin", "index.d.ts", "react.d.ts", "template.json", "README.md", "LICENSE"],
22
+ "sideEffects": ["./src/embed.js"],
23
+ "peerDependencies": { "react": ">=18" },
24
+ "peerDependenciesMeta": { "react": { "optional": true } },
25
+ "engines": { "node": ">=18" },
26
+ "scripts": {
27
+ "build": "node scripts/build-json.js",
28
+ "test": "node --test",
29
+ "prepack": "node scripts/build-json.js"
30
+ }
31
+ }
package/react.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { FooterOptions } from './index.js';
2
+ /** Async React server component (Next App Router, React 19 SSR). */
3
+ export declare function Footer(props: FooterOptions): Promise<any>;
4
+ /** Synchronous, bundled template; for client components or React 18 renderToString. */
5
+ export declare function FooterSync(props: FooterOptions): any;
6
+ export default Footer;
package/src/embed.js ADDED
@@ -0,0 +1,45 @@
1
+ // For static HTML sites with no server to render on. Load from the CDN:
2
+ //
3
+ // <footer data-profullstack-footer data-site="https://example.com/"
4
+ // data-links='[{"label":"Docs","href":"/docs"}]'>
5
+ // ...the ring links, server-rendered, as the fallback crawlers see...
6
+ // </footer>
7
+ // <script type="module" src="https://cdn.jsdelivr.net/npm/@profullstack/footer@latest/src/embed.js"></script>
8
+ //
9
+ // Keep real ring links inside the element: the ring's verifier reads the HTML
10
+ // and never runs this script. `profullstack-footer html --site ...` prints them.
11
+
12
+ import { footerHtml } from './index.js';
13
+
14
+ /** @param {string | undefined} raw */
15
+ function parse(raw) {
16
+ try {
17
+ return raw ? JSON.parse(raw) : undefined;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ export async function mount(root = document) {
24
+ const nodes = root.querySelectorAll('[data-profullstack-footer]');
25
+ for (const el of nodes) {
26
+ const d = /** @type {HTMLElement} */ (el).dataset;
27
+ const site = d.site || location.origin + '/';
28
+ const html = await footerHtml({
29
+ site,
30
+ name: d.name,
31
+ tagline: d.tagline,
32
+ since: d.since ? Number(d.since) : undefined,
33
+ links: parse(d.links),
34
+ owner: parse(d.owner),
35
+ });
36
+ const tpl = document.createElement('template');
37
+ tpl.innerHTML = html;
38
+ el.replaceWith(tpl.content);
39
+ }
40
+ }
41
+
42
+ if (typeof document !== 'undefined') {
43
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => mount());
44
+ else mount();
45
+ }
package/src/index.js ADDED
@@ -0,0 +1,125 @@
1
+ // @profullstack/footer: one footer for every Profullstack site.
2
+ //
3
+ // The markup lives in template.js and ships as template.json. `footerHtml`
4
+ // fetches the @latest template from the CDN at render time (cached, with the
5
+ // bundled copy as the fallback), so publishing a release is the whole
6
+ // rollout: no site has to bump, rebuild or redeploy.
7
+
8
+ import bundled from './template.js';
9
+ import { RENDERER, renderFooter, descriptor } from './render.js';
10
+
11
+ export {
12
+ RENDERER,
13
+ DEFAULT_OWNER,
14
+ DEFAULT_RING,
15
+ canonicalSite,
16
+ descriptor,
17
+ escapeHtml,
18
+ renderFooter,
19
+ ringLinks,
20
+ sanitizeTemplate,
21
+ } from './render.js';
22
+ export { bundled as bundledTemplate };
23
+
24
+ export const TEMPLATE_URL = 'https://cdn.jsdelivr.net/npm/@profullstack/footer@latest/template.json';
25
+
26
+ const DEFAULT_TTL_MS = 60 * 60 * 1000;
27
+ const FAILURE_TTL_MS = 5 * 60 * 1000;
28
+
29
+ /** @type {{ template: any, until: number, pending: Promise<any> | null }} */
30
+ const cache = { template: null, until: 0, pending: null };
31
+
32
+ /** @param {any} t */
33
+ function usable(t) {
34
+ return (
35
+ t &&
36
+ typeof t === 'object' &&
37
+ typeof t.footer === 'string' &&
38
+ typeof t.link === 'string' &&
39
+ typeof t.css === 'string' &&
40
+ Number(t.renderer ?? 1) <= RENDERER
41
+ );
42
+ }
43
+
44
+ /**
45
+ * The newest template this install can render. Never throws and never waits
46
+ * longer than `timeoutMs`: a slow or broken CDN gives the bundled template.
47
+ *
48
+ * @param {{ url?: string, ttlMs?: number, timeoutMs?: number, fetch?: typeof fetch, now?: () => number }} [opts]
49
+ */
50
+ export async function latestTemplate(opts = {}) {
51
+ const env = typeof process !== 'undefined' ? process.env ?? {} : {};
52
+ if (env.PROFULLSTACK_FOOTER_OFFLINE === '1') return bundled;
53
+ const now = (opts.now ?? Date.now)();
54
+ if (cache.template && now < cache.until) return cache.template;
55
+ // Stale while revalidating: hand back what we have, refresh once.
56
+ if (cache.template && cache.pending) return cache.template;
57
+
58
+ const doFetch = opts.fetch ?? globalThis.fetch;
59
+ if (typeof doFetch !== 'function') return bundled;
60
+ const url = opts.url ?? env.PROFULLSTACK_FOOTER_TEMPLATE_URL ?? TEMPLATE_URL;
61
+ const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
62
+
63
+ cache.pending = (async () => {
64
+ try {
65
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
66
+ const timer = ctrl ? setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 1500) : null;
67
+ /** @type {RequestInit & { next?: { revalidate: number } }} */
68
+ const init = { signal: ctrl?.signal, headers: { accept: 'application/json' }, next: { revalidate: Math.round(ttl / 1000) } };
69
+ const res = await doFetch(url, init).finally(() => timer && clearTimeout(timer));
70
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
71
+ const t = await res.json();
72
+ if (!usable(t)) throw new Error('template not usable by this renderer');
73
+ cache.template = t;
74
+ cache.until = (opts.now ?? Date.now)() + ttl;
75
+ } catch {
76
+ cache.template = cache.template ?? bundled;
77
+ cache.until = (opts.now ?? Date.now)() + FAILURE_TTL_MS;
78
+ } finally {
79
+ cache.pending = null;
80
+ }
81
+ return cache.template;
82
+ })();
83
+
84
+ return cache.template ?? cache.pending;
85
+ }
86
+
87
+ /** Forget the cached template (tests, or a manual refresh hook). */
88
+ export function resetTemplateCache() {
89
+ cache.template = null;
90
+ cache.until = 0;
91
+ cache.pending = null;
92
+ }
93
+
94
+ /**
95
+ * The footer HTML using the @latest template. Use this on the server.
96
+ * @param {import('./render.js').FooterOptions & { template?: import('./render.js').FooterTemplate }} opts
97
+ */
98
+ export async function footerHtml(opts) {
99
+ const template = opts.template ?? (await latestTemplate());
100
+ return renderFooter(template, opts);
101
+ }
102
+
103
+ /**
104
+ * The footer HTML from the template bundled in this install, with no network.
105
+ * For build-time rendering and places that cannot await.
106
+ * @param {import('./render.js').FooterOptions} opts
107
+ */
108
+ export function footerHtmlSync(opts) {
109
+ return renderFooter(bundled, opts);
110
+ }
111
+
112
+ /**
113
+ * A ready Response for a /.well-known/openwebring.json route (Next route
114
+ * handlers, Hono, Bun.serve, Workers).
115
+ * @param {Parameters<typeof descriptor>[0]} opts
116
+ */
117
+ export function descriptorResponse(opts) {
118
+ return new Response(JSON.stringify(descriptor(opts), null, 2) + '\n', {
119
+ headers: {
120
+ 'content-type': 'application/json; charset=utf-8',
121
+ 'cache-control': 'public, max-age=3600',
122
+ 'access-control-allow-origin': '*',
123
+ },
124
+ });
125
+ }
package/src/react.js ADDED
@@ -0,0 +1,19 @@
1
+ // React server component. No hooks and no JSX, so it renders on the server in
2
+ // Next's App Router (and any React 18+ SSR) and needs no build step here.
3
+ // Async: the template is the CDN's @latest, fetched and cached by index.js.
4
+
5
+ import { createElement } from 'react';
6
+ import { footerHtml, footerHtmlSync } from './index.js';
7
+
8
+ /** @param {import('./render.js').FooterOptions} props */
9
+ export async function Footer(props) {
10
+ const html = await footerHtml(props);
11
+ return createElement('div', { style: { display: 'contents' }, dangerouslySetInnerHTML: { __html: html } });
12
+ }
13
+
14
+ /** Synchronous variant from the bundled template, for client components or old React. */
15
+ export function FooterSync(props) {
16
+ return createElement('div', { style: { display: 'contents' }, dangerouslySetInnerHTML: { __html: footerHtmlSync(props) } });
17
+ }
18
+
19
+ export default Footer;
package/src/render.js ADDED
@@ -0,0 +1,160 @@
1
+ // Pure rendering: no I/O, safe in any runtime (Node, Bun, edge, browser).
2
+
3
+ export const RENDERER = 1;
4
+
5
+ export const DEFAULT_RING = {
6
+ slug: 'profullstack',
7
+ name: 'Profullstack',
8
+ host: 'https://rssamplifier.com',
9
+ };
10
+
11
+ export const DEFAULT_OWNER = { name: 'Profullstack, Inc.', url: 'https://profullstack.com/' };
12
+
13
+ const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
14
+
15
+ /** @param {unknown} value */
16
+ export function escapeHtml(value) {
17
+ return String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
18
+ }
19
+
20
+ /**
21
+ * The canonical homepage a ring hop is measured from: scheme, host and a
22
+ * trailing slash. `example.com` and `https://example.com/blog?x=1` both give
23
+ * `https://example.com/`.
24
+ * @param {string} site
25
+ */
26
+ export function canonicalSite(site) {
27
+ const raw = String(site ?? '').trim();
28
+ if (!raw) throw new TypeError('@profullstack/footer: `site` is required (e.g. "https://example.com/")');
29
+ const url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
30
+ return `${url.protocol}//${url.host}/`;
31
+ }
32
+
33
+ /**
34
+ * The four ring URLs for a site. Hops are server-side redirects on the ring
35
+ * host, so the order of the ring never lives in a site's code.
36
+ * @param {string} site
37
+ * @param {{ slug?: string, host?: string }} [ring]
38
+ */
39
+ export function ringLinks(site, ring = {}) {
40
+ const host = String(ring.host ?? DEFAULT_RING.host).replace(/\/+$/, '');
41
+ const base = `${host}/ring/${encodeURIComponent(ring.slug ?? DEFAULT_RING.slug)}`;
42
+ const from = encodeURIComponent(canonicalSite(site));
43
+ return {
44
+ ring: base,
45
+ previous: `${base}/previous?from=${from}`,
46
+ next: `${base}/next?from=${from}`,
47
+ random: `${base}/random?from=${from}`,
48
+ };
49
+ }
50
+
51
+ /** @param {number} since @param {number} year */
52
+ export function years(since, year) {
53
+ return since && since < year ? `${since}–${year}` : String(year);
54
+ }
55
+
56
+ /**
57
+ * Strip what a template must never carry. Templates arrive over the network
58
+ * from the CDN and land in every site's HTML, so a bad publish must not be
59
+ * able to run script there.
60
+ * @param {string} html
61
+ */
62
+ export function sanitizeTemplate(html) {
63
+ return String(html ?? '')
64
+ .replace(/<\s*(script|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
65
+ .replace(/<\s*\/?\s*(script|iframe|object|embed|link|meta|base|form|noscript|template|svg|math)\b[^>]*>/gi, '')
66
+ .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
67
+ .replace(/(href|src|action)\s*=\s*("|')\s*(javascript|data|vbscript):[^"']*\2/gi, '$1=$2#$2');
68
+ }
69
+
70
+ /** @param {string} css */
71
+ export function sanitizeCss(css) {
72
+ return String(css ?? '')
73
+ .replace(/<\/?\s*style/gi, '')
74
+ .replace(/@import[^;]*;?/gi, '')
75
+ .replace(/expression\s*\(/gi, '(')
76
+ .replace(/url\s*\(\s*(['"]?)\s*javascript:[^)]*\)/gi, 'none');
77
+ }
78
+
79
+ /**
80
+ * Fill `{{x}}`, `{{{x}}}` and `{{#x}}...{{/x}}`. Unknown names render empty.
81
+ * @param {string} tpl
82
+ * @param {Record<string, unknown>} vars
83
+ */
84
+ export function fill(tpl, vars) {
85
+ const sections = /\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g;
86
+ let out = String(tpl);
87
+ // Sections first (twice handles one level of nesting).
88
+ for (let i = 0; i < 2; i++) {
89
+ out = out.replace(sections, (_, name, body) => {
90
+ const v = vars[name];
91
+ return v === undefined || v === null || v === false || v === '' || (Array.isArray(v) && !v.length) ? '' : body;
92
+ });
93
+ }
94
+ return out
95
+ .replace(/\{\{\{(\w+)\}\}\}/g, (_, name) => String(vars[name] ?? ''))
96
+ .replace(/\{\{(\w+)\}\}/g, (_, name) => escapeHtml(vars[name]));
97
+ }
98
+
99
+ /**
100
+ * @typedef {{ label: string, href: string, rel?: string }} FooterLink
101
+ * @typedef {{
102
+ * site: string,
103
+ * name?: string,
104
+ * links?: FooterLink[],
105
+ * owner?: { name?: string, url?: string },
106
+ * since?: number,
107
+ * year?: number,
108
+ * tagline?: string,
109
+ * ring?: { slug?: string, name?: string, host?: string },
110
+ * css?: boolean,
111
+ * houseLinks?: boolean,
112
+ * }} FooterOptions
113
+ * @typedef {{ version: string, renderer: number, css: string, link: string, footer: string, houseLinks?: FooterLink[] }} FooterTemplate
114
+ */
115
+
116
+ /**
117
+ * Render the footer from a template. Synchronous and pure.
118
+ * @param {FooterTemplate} template
119
+ * @param {FooterOptions} opts
120
+ */
121
+ export function renderFooter(template, opts) {
122
+ const ring = { ...DEFAULT_RING, ...(opts.ring ?? {}) };
123
+ const owner = { ...DEFAULT_OWNER, ...(opts.owner ?? {}) };
124
+ const hops = ringLinks(opts.site, ring);
125
+ const year = opts.year ?? new Date().getUTCFullYear();
126
+ const linkTpl = sanitizeTemplate(template.link);
127
+ const all = [...(opts.links ?? []), ...(opts.houseLinks === false ? [] : (template.houseLinks ?? []))];
128
+ const links = all
129
+ .filter((l) => l && l.href && l.label && !/^\s*(javascript|data|vbscript):/i.test(l.href))
130
+ .map((l) => fill(linkTpl, { href: l.href, label: l.label, rel: l.rel ?? '' }))
131
+ .join('');
132
+ return fill(sanitizeTemplate(template.footer), {
133
+ version: template.version,
134
+ css: opts.css === false ? '' : sanitizeCss(template.css),
135
+ links,
136
+ ...hops,
137
+ ring_name: ring.name,
138
+ years: years(opts.since ?? 0, year),
139
+ owner_name: owner.name,
140
+ owner_url: owner.url,
141
+ tagline: opts.tagline ?? '',
142
+ site: canonicalSite(opts.site),
143
+ name: opts.name ?? '',
144
+ });
145
+ }
146
+
147
+ /**
148
+ * The /.well-known/openwebring.json body that states the site's membership.
149
+ * @param {{ site: string, name?: string, slug?: string, madeBy?: 'human'|'ai'|'both', ring?: { slug?: string, host?: string } }} opts
150
+ */
151
+ export function descriptor(opts) {
152
+ const site = canonicalSite(opts.site);
153
+ const { ring } = ringLinks(site, opts.ring);
154
+ return {
155
+ openwebring: '0.1',
156
+ site: { url: site, name: opts.name ?? new URL(site).host },
157
+ made_by: opts.madeBy ?? 'both',
158
+ rings: [{ ring, slug: opts.slug ?? new URL(site).host.replace(/^www\./, '').replace(/\./g, '-') }],
159
+ };
160
+ }
@@ -0,0 +1,64 @@
1
+ // The footer itself, as data. This object is what changes between releases:
2
+ // it is published as template.json and every installed copy fetches the
3
+ // @latest one at render time (see index.js), so a release reaches every site
4
+ // without a redeploy. Placeholders: {{name}} is escaped, {{{name}}} is raw
5
+ // (only for values this package builds itself), {{#name}}...{{/name}} keeps
6
+ // the block when the value is non-empty.
7
+ //
8
+ // Bump `renderer` only when the template needs placeholders an older
9
+ // render.js does not fill; older installs then keep their bundled template.
10
+
11
+ // OpenIcon line glyphs (github.com/profullstack/openicon, keys `random` and
12
+ // `vote`) as CSS masks. A template may not carry <svg> (sanitizeTemplate strips
13
+ // it), and a mask over currentColor keeps the icon the colour of the link.
14
+ const glyph = (body) =>
15
+ `url("data:image/svg+xml,${encodeURIComponent(
16
+ `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'>${body}</svg>`,
17
+ )}")`;
18
+ const RANDOM = glyph(
19
+ "<rect x='3' y='3' width='18' height='18' rx='3'/><circle cx='8' cy='8' r='1'/><circle cx='16' cy='8' r='1'/><circle cx='12' cy='12' r='1'/><circle cx='8' cy='16' r='1'/><circle cx='16' cy='16' r='1'/>",
20
+ );
21
+ const VOTE = glyph("<path d='M12 4 21 19H3z'/>");
22
+
23
+ export default {
24
+ version: '0.2.0',
25
+ renderer: 1,
26
+ css: [
27
+ '.pfs-footer{--pfs-footer-muted:color-mix(in srgb,currentColor 62%,transparent);--pfs-footer-gap:.5rem 1.25rem;--pfs-footer-size:.875rem;',
28
+ 'border-top:1px solid color-mix(in srgb,currentColor 14%,transparent);font-size:var(--pfs-footer-size);line-height:1.5;color:inherit}',
29
+ '.pfs-footer__inner{max-width:72rem;margin:0 auto;padding:1.5rem 1rem;display:flex;flex-direction:column;align-items:center;gap:.75rem;text-align:center}',
30
+ '.pfs-footer a{color:var(--pfs-footer-muted);text-decoration:none}',
31
+ '.pfs-footer a:hover,.pfs-footer a:focus-visible{color:inherit;text-decoration:underline}',
32
+ '.pfs-footer__links{display:flex;flex-wrap:wrap;justify-content:center;gap:var(--pfs-footer-gap)}',
33
+ '.pfs-footer__ring{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.6rem}',
34
+ '.pfs-footer__icon{display:inline-block;width:1em;height:1em;vertical-align:-.125em;background-color:currentColor;',
35
+ '-webkit-mask:var(--pfs-footer-glyph) center/contain no-repeat;mask:var(--pfs-footer-glyph) center/contain no-repeat}',
36
+ `.pfs-footer__icon--random{--pfs-footer-glyph:${RANDOM}}`,
37
+ `.pfs-footer__icon--vote{--pfs-footer-glyph:${VOTE}}`,
38
+ '.pfs-footer__copy{margin:0;color:var(--pfs-footer-muted)}',
39
+ '.pfs-footer__copy a{color:inherit}',
40
+ ].join(''),
41
+ link: '<a href="{{href}}"{{#rel}} rel="{{rel}}"{{/rel}}>{{label}}</a>',
42
+ footer: [
43
+ '<footer class="pfs-footer" data-pfs-footer="{{version}}">',
44
+ '{{#css}}<style>{{{css}}}</style>{{/css}}',
45
+ '<div class="pfs-footer__inner">',
46
+ '{{#links}}<nav class="pfs-footer__links" aria-label="Footer">{{{links}}}</nav>{{/links}}',
47
+ '<nav class="webring pfs-footer__ring" aria-label="{{ring_name}} webring">',
48
+ '<a href="{{previous}}" rel="prev" title="Previous site">&lt;&lt;</a>',
49
+ '<a href="{{ring}}">{{ring_name}}</a>',
50
+ '<a href="{{next}}" rel="next" title="Next site">&gt;&gt;</a>',
51
+ '<a href="{{random}}" title="Random site" aria-label="Random site"><span class="pfs-footer__icon pfs-footer__icon--random" aria-hidden="true"></span></a>',
52
+ // Anonymous, throttled vote for this site. A bare URL because a new
53
+ // placeholder would need a renderer bump; the ring host finds the site
54
+ // from the Referer, and referrerpolicy keeps the origin on sites whose
55
+ // own policy is no-referrer.
56
+ '<a href="{{ring}}/vote" referrerpolicy="origin" title="Vote for this site" aria-label="Vote for this site"><span class="pfs-footer__icon pfs-footer__icon--vote" aria-hidden="true"></span></a>',
57
+ '</nav>',
58
+ '<p class="pfs-footer__copy">&copy; {{years}} <a href="{{owner_url}}">{{owner_name}}</a>{{#tagline}} &middot; {{tagline}}{{/tagline}}</p>',
59
+ '</div>',
60
+ '</footer>',
61
+ ].join(''),
62
+ // Links every site shows after its own, e.g. a house status page.
63
+ houseLinks: [],
64
+ };
package/template.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "renderer": 1,
4
+ "css": ".pfs-footer{--pfs-footer-muted:color-mix(in srgb,currentColor 62%,transparent);--pfs-footer-gap:.5rem 1.25rem;--pfs-footer-size:.875rem;border-top:1px solid color-mix(in srgb,currentColor 14%,transparent);font-size:var(--pfs-footer-size);line-height:1.5;color:inherit}.pfs-footer__inner{max-width:72rem;margin:0 auto;padding:1.5rem 1rem;display:flex;flex-direction:column;align-items:center;gap:.75rem;text-align:center}.pfs-footer a{color:var(--pfs-footer-muted);text-decoration:none}.pfs-footer a:hover,.pfs-footer a:focus-visible{color:inherit;text-decoration:underline}.pfs-footer__links{display:flex;flex-wrap:wrap;justify-content:center;gap:var(--pfs-footer-gap)}.pfs-footer__ring{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.6rem}.pfs-footer__icon{display:inline-block;width:1em;height:1em;vertical-align:-.125em;background-color:currentColor;-webkit-mask:var(--pfs-footer-glyph) center/contain no-repeat;mask:var(--pfs-footer-glyph) center/contain no-repeat}.pfs-footer__icon--random{--pfs-footer-glyph:url(\"data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Crect%20x%3D'3'%20y%3D'3'%20width%3D'18'%20height%3D'18'%20rx%3D'3'%2F%3E%3Ccircle%20cx%3D'8'%20cy%3D'8'%20r%3D'1'%2F%3E%3Ccircle%20cx%3D'16'%20cy%3D'8'%20r%3D'1'%2F%3E%3Ccircle%20cx%3D'12'%20cy%3D'12'%20r%3D'1'%2F%3E%3Ccircle%20cx%3D'8'%20cy%3D'16'%20r%3D'1'%2F%3E%3Ccircle%20cx%3D'16'%20cy%3D'16'%20r%3D'1'%2F%3E%3C%2Fsvg%3E\")}.pfs-footer__icon--vote{--pfs-footer-glyph:url(\"data:image/svg+xml,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2024%2024'%20fill%3D'none'%20stroke%3D'black'%20stroke-width%3D'2'%20stroke-linecap%3D'round'%20stroke-linejoin%3D'round'%3E%3Cpath%20d%3D'M12%204%2021%2019H3z'%2F%3E%3C%2Fsvg%3E\")}.pfs-footer__copy{margin:0;color:var(--pfs-footer-muted)}.pfs-footer__copy a{color:inherit}",
5
+ "link": "<a href=\"{{href}}\"{{#rel}} rel=\"{{rel}}\"{{/rel}}>{{label}}</a>",
6
+ "footer": "<footer class=\"pfs-footer\" data-pfs-footer=\"{{version}}\">{{#css}}<style>{{{css}}}</style>{{/css}}<div class=\"pfs-footer__inner\">{{#links}}<nav class=\"pfs-footer__links\" aria-label=\"Footer\">{{{links}}}</nav>{{/links}}<nav class=\"webring pfs-footer__ring\" aria-label=\"{{ring_name}} webring\"><a href=\"{{previous}}\" rel=\"prev\" title=\"Previous site\">&lt;&lt;</a><a href=\"{{ring}}\">{{ring_name}}</a><a href=\"{{next}}\" rel=\"next\" title=\"Next site\">&gt;&gt;</a><a href=\"{{random}}\" title=\"Random site\" aria-label=\"Random site\"><span class=\"pfs-footer__icon pfs-footer__icon--random\" aria-hidden=\"true\"></span></a><a href=\"{{ring}}/vote\" referrerpolicy=\"origin\" title=\"Vote for this site\" aria-label=\"Vote for this site\"><span class=\"pfs-footer__icon pfs-footer__icon--vote\" aria-hidden=\"true\"></span></a></nav><p class=\"pfs-footer__copy\">&copy; {{years}} <a href=\"{{owner_url}}\">{{owner_name}}</a>{{#tagline}} &middot; {{tagline}}{{/tagline}}</p></div></footer>",
7
+ "houseLinks": []
8
+ }