@profullstack/footer 0.2.6 → 0.3.1
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/README.md +14 -0
- package/bin/profullstack-footer.js +3 -0
- package/index.d.ts +15 -0
- package/package.json +1 -1
- package/src/embed.js +1 -0
- package/src/index.js +3 -0
- package/src/render.js +51 -0
- package/src/template.js +16 -1
- package/template.json +3 -3
package/README.md
CHANGED
|
@@ -72,11 +72,25 @@ npx @profullstack/footer html --site https://example.com/ --link Docs=/docs
|
|
|
72
72
|
| `ring` | `{ slug: 'profullstack', name: 'Profullstack', host: 'https://rssamplifier.com' }` | |
|
|
73
73
|
| `css` | `true` | `false` drops the `<style>`; style `.pfs-footer*` yourself |
|
|
74
74
|
| `houseLinks` | `true` | `false` hides links the template adds for every site |
|
|
75
|
+
| `subscribe` | `true` | `false` hides the newsletter form |
|
|
76
|
+
| `badges` | `[]` | `{ href, src, alt, width?, height? }[]`: badges only this site shows, after the house badges |
|
|
75
77
|
| `nonce` | | CSP nonce for the inline `<style>`; or `css: false` and style `.pfs-footer*` yourself |
|
|
76
78
|
|
|
77
79
|
Theme it with `--pfs-footer-muted`, `--pfs-footer-gap` and `--pfs-footer-size` on
|
|
78
80
|
`.pfs-footer`; colors otherwise inherit from the page.
|
|
79
81
|
|
|
82
|
+
## Newsletter form
|
|
83
|
+
|
|
84
|
+
Every footer carries a small email field and a Subscribe button: a plain HTML form (no script)
|
|
85
|
+
that posts to `https://pit.world/subscribe` with the site's hostname. pit.world runs the double
|
|
86
|
+
opt-in (confirmation email, nothing kept until the click), answers with a "check your inbox" page
|
|
87
|
+
that links back to the site, and records which property each subscriber came from
|
|
88
|
+
(`https://pit.world/admin/subscribers`). The endpoint is fixed in render.js, never in the template.
|
|
89
|
+
|
|
90
|
+
**CSP:** a site that sets `form-action` must allow it: `form-action 'self' https://pit.world`.
|
|
91
|
+
Turn it off with `subscribe: false` (`--no-subscribe`). Installs before 0.3.0 cannot render the
|
|
92
|
+
form; their footer shows a plain "Newsletter" link to the same page instead, from the @latest template.
|
|
93
|
+
|
|
80
94
|
## CLI
|
|
81
95
|
|
|
82
96
|
```sh
|
|
@@ -20,6 +20,7 @@ const HELP = `usage: profullstack-footer <html|json|descriptor|template|check> [
|
|
|
20
20
|
--slug <slug> ring member slug (descriptor; default host with dashes)
|
|
21
21
|
--offline use the bundled template, do not fetch @latest
|
|
22
22
|
--no-css leave the <style> out
|
|
23
|
+
--no-subscribe leave the newsletter form out
|
|
23
24
|
`;
|
|
24
25
|
|
|
25
26
|
const { values, positionals } = parseArgs({
|
|
@@ -33,6 +34,7 @@ const { values, positionals } = parseArgs({
|
|
|
33
34
|
slug: { type: 'string' },
|
|
34
35
|
offline: { type: 'boolean' },
|
|
35
36
|
'no-css': { type: 'boolean' },
|
|
37
|
+
'no-subscribe': { type: 'boolean' },
|
|
36
38
|
help: { type: 'boolean', short: 'h' },
|
|
37
39
|
},
|
|
38
40
|
});
|
|
@@ -62,6 +64,7 @@ const opts = () => ({
|
|
|
62
64
|
since: values.since ? Number(values.since) : undefined,
|
|
63
65
|
tagline: values.tagline,
|
|
64
66
|
css: !values['no-css'],
|
|
67
|
+
subscribe: !values['no-subscribe'],
|
|
65
68
|
});
|
|
66
69
|
|
|
67
70
|
switch (cmd) {
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export interface FooterLink { label: string; href: string; rel?: string }
|
|
2
|
+
export interface FooterBadge {
|
|
3
|
+
href: string;
|
|
4
|
+
/** Image URL (http(s) or root-relative). */
|
|
5
|
+
src: string;
|
|
6
|
+
alt: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
height?: number;
|
|
9
|
+
}
|
|
2
10
|
export interface RingOptions { slug?: string; name?: string; host?: string }
|
|
3
11
|
export interface FooterOptions {
|
|
4
12
|
/** The site's homepage; hops are measured from it. */
|
|
@@ -20,6 +28,10 @@ export interface FooterOptions {
|
|
|
20
28
|
houseLinks?: boolean;
|
|
21
29
|
/** CSP nonce for the inline <style> (style-src 'nonce-...'). */
|
|
22
30
|
nonce?: string;
|
|
31
|
+
/** false hides the newsletter form (it posts to SUBSCRIBE_URL; CSP form-action must allow https://pit.world). */
|
|
32
|
+
subscribe?: boolean;
|
|
33
|
+
/** Badges only this site shows, after the house badges (e.g. a game's Foundplay badge). */
|
|
34
|
+
badges?: FooterBadge[];
|
|
23
35
|
}
|
|
24
36
|
export interface FooterTemplate {
|
|
25
37
|
version: string;
|
|
@@ -46,6 +58,9 @@ export interface DescriptorOptions {
|
|
|
46
58
|
|
|
47
59
|
export declare const RENDERER: number;
|
|
48
60
|
export declare const TEMPLATE_URL: string;
|
|
61
|
+
export declare const SUBSCRIBE_URL: string;
|
|
62
|
+
export declare function subscribeForm(site: string): string;
|
|
63
|
+
export declare function siteBadges(badges?: FooterBadge[]): string;
|
|
49
64
|
export declare const DEFAULT_RING: Required<RingOptions>;
|
|
50
65
|
export declare const DEFAULT_OWNER: { name: string; url: string };
|
|
51
66
|
export declare const bundledTemplate: FooterTemplate;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/footer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
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
6
|
"keywords": ["footer", "webring", "openwebring", "copyright", "react", "nextjs", "hono", "ssr"],
|
package/src/embed.js
CHANGED
package/src/index.js
CHANGED
package/src/render.js
CHANGED
|
@@ -10,6 +10,11 @@ export const DEFAULT_RING = {
|
|
|
10
10
|
|
|
11
11
|
export const DEFAULT_OWNER = { name: 'Profullstack, Inc.', url: 'https://profullstack.com/' };
|
|
12
12
|
|
|
13
|
+
// The one newsletter endpoint for every Profullstack property (double opt-in, keeps the site's host).
|
|
14
|
+
// Fixed here, never taken from the template: a CDN template cannot carry a <form> (sanitizeTemplate
|
|
15
|
+
// strips them), so it can only say WHERE the form goes ({{{subscribe}}}), not where it posts.
|
|
16
|
+
export const SUBSCRIBE_URL = 'https://pit.world/subscribe';
|
|
17
|
+
|
|
13
18
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
14
19
|
|
|
15
20
|
/** @param {unknown} value */
|
|
@@ -48,6 +53,47 @@ export function ringLinks(site, ring = {}) {
|
|
|
48
53
|
};
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The low-profile newsletter form: plain HTML, works with no script, posts the site's hostname so the
|
|
58
|
+
* signup records which property it came from. The endpoint answers with a "check your inbox" page and
|
|
59
|
+
* a link back here.
|
|
60
|
+
* @param {string} site
|
|
61
|
+
*/
|
|
62
|
+
export function subscribeForm(site) {
|
|
63
|
+
const host = new URL(canonicalSite(site)).hostname;
|
|
64
|
+
return (
|
|
65
|
+
`<form class="pfs-footer__sub" action="${SUBSCRIBE_URL}" method="post">` +
|
|
66
|
+
`<input type="hidden" name="site" value="${escapeHtml(host)}">` +
|
|
67
|
+
'<input class="pfs-footer__hp" name="website" type="text" tabindex="-1" autocomplete="off" aria-hidden="true">' +
|
|
68
|
+
'<input type="email" name="email" required maxlength="254" autocomplete="email" placeholder="Email for our newsletter" aria-label="Email address for the Profullstack newsletter">' +
|
|
69
|
+
'<button type="submit">Subscribe</button>' +
|
|
70
|
+
'</form>'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SAFE_URL = /^\s*(https?:\/\/|\/(?!\/))/i;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Badges only this site shows, after the house badges: an image link each. Only http(s) or
|
|
78
|
+
* root-relative URLs; anything else is dropped.
|
|
79
|
+
* @param {FooterBadge[] | undefined} badges
|
|
80
|
+
*/
|
|
81
|
+
export function siteBadges(badges) {
|
|
82
|
+
return (badges ?? [])
|
|
83
|
+
.filter((b) => b && b.href && b.src && SAFE_URL.test(b.href) && SAFE_URL.test(b.src))
|
|
84
|
+
.map((b) => {
|
|
85
|
+
const size = ['width', 'height']
|
|
86
|
+
.filter((k) => Number.isFinite(Number(b[k])) && Number(b[k]) > 0)
|
|
87
|
+
.map((k) => ` ${k}="${Math.round(Number(b[k]))}"`)
|
|
88
|
+
.join('');
|
|
89
|
+
return (
|
|
90
|
+
`<a class="pfs-footer__badge pfs-footer__badge--site" href="${escapeHtml(b.href)}">` +
|
|
91
|
+
`<img src="${escapeHtml(b.src)}" alt="${escapeHtml(b.alt ?? '')}"${size} loading="lazy"></a>`
|
|
92
|
+
);
|
|
93
|
+
})
|
|
94
|
+
.join('');
|
|
95
|
+
}
|
|
96
|
+
|
|
51
97
|
/** @param {number} since @param {number} year */
|
|
52
98
|
export function years(since, year) {
|
|
53
99
|
return since && since < year ? `${since}–${year}` : String(year);
|
|
@@ -98,6 +144,7 @@ export function fill(tpl, vars) {
|
|
|
98
144
|
|
|
99
145
|
/**
|
|
100
146
|
* @typedef {{ label: string, href: string, rel?: string }} FooterLink
|
|
147
|
+
* @typedef {{ href: string, src: string, alt: string, width?: number, height?: number }} FooterBadge
|
|
101
148
|
* @typedef {{
|
|
102
149
|
* site: string,
|
|
103
150
|
* name?: string,
|
|
@@ -110,6 +157,8 @@ export function fill(tpl, vars) {
|
|
|
110
157
|
* css?: boolean,
|
|
111
158
|
* houseLinks?: boolean,
|
|
112
159
|
* nonce?: string,
|
|
160
|
+
* subscribe?: boolean,
|
|
161
|
+
* badges?: FooterBadge[],
|
|
113
162
|
* }} FooterOptions
|
|
114
163
|
* @typedef {{ version: string, renderer: number, css: string, link: string, footer: string, houseLinks?: FooterLink[] }} FooterTemplate
|
|
115
164
|
*/
|
|
@@ -143,6 +192,8 @@ export function renderFooter(template, opts) {
|
|
|
143
192
|
site: canonicalSite(opts.site),
|
|
144
193
|
name: opts.name ?? '',
|
|
145
194
|
nonce: opts.nonce ?? '',
|
|
195
|
+
subscribe: opts.subscribe === false ? '' : subscribeForm(opts.site),
|
|
196
|
+
badges: siteBadges(opts.badges),
|
|
146
197
|
});
|
|
147
198
|
}
|
|
148
199
|
|
package/src/template.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// render.js does not fill; older installs then keep their bundled template.
|
|
10
10
|
|
|
11
11
|
export default {
|
|
12
|
-
version: '0.
|
|
12
|
+
version: '0.3.1',
|
|
13
13
|
renderer: 1,
|
|
14
14
|
css: [
|
|
15
15
|
'.pfs-footer{--pfs-footer-muted:color-mix(in srgb,currentColor 62%,transparent);--pfs-footer-gap:.5rem 1.25rem;--pfs-footer-size:.875rem;',
|
|
@@ -22,6 +22,16 @@ export default {
|
|
|
22
22
|
'.pfs-footer__badges{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.6rem}',
|
|
23
23
|
'.pfs-footer a.pfs-footer__badge{display:inline-block;line-height:0}',
|
|
24
24
|
'.pfs-footer__badge img{display:block;width:200px;height:44px;max-width:100%}',
|
|
25
|
+
'.pfs-footer__badge--site img{width:auto;height:auto}',
|
|
26
|
+
'.pfs-footer__news{display:flex;justify-content:center}',
|
|
27
|
+
'.pfs-footer__sub{display:flex;align-items:center;gap:.35rem;margin:0;flex-wrap:wrap;justify-content:center}',
|
|
28
|
+
'.pfs-footer__sub input[type=email]{font:inherit;font-size:.8125rem;color:inherit;background:transparent;width:13.5rem;max-width:62vw;padding:.2rem .5rem;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:.35rem}',
|
|
29
|
+
'.pfs-footer__sub input[type=email]::placeholder{color:var(--pfs-footer-muted);opacity:1}',
|
|
30
|
+
'.pfs-footer__sub input[type=email]:focus-visible{outline:2px solid color-mix(in srgb,currentColor 45%,transparent);outline-offset:1px}',
|
|
31
|
+
'.pfs-footer__sub button{font:inherit;font-size:.8125rem;color:var(--pfs-footer-muted);background:transparent;padding:.2rem .6rem;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:.35rem;cursor:pointer}',
|
|
32
|
+
'.pfs-footer__sub button:hover,.pfs-footer__sub button:focus-visible{color:inherit}',
|
|
33
|
+
'.pfs-footer__hp{position:absolute!important;left:-9999px!important;width:1px;height:1px;opacity:0}',
|
|
34
|
+
'.pfs-footer__sub+.pfs-footer__sublink{display:none}',
|
|
25
35
|
'.pfs-footer__copy{margin:0;color:var(--pfs-footer-muted)}',
|
|
26
36
|
'.pfs-footer__copy a{color:inherit}',
|
|
27
37
|
].join(''),
|
|
@@ -36,9 +46,14 @@ export default {
|
|
|
36
46
|
'<a href="{{ring}}">{{ring_name}}</a>',
|
|
37
47
|
'<a href="{{next}}" rel="next" title="Next site">>></a>',
|
|
38
48
|
'</nav>',
|
|
49
|
+
// The newsletter form comes from render.js ({{{subscribe}}}). Installs older than 0.3.0 cannot render
|
|
50
|
+
// it and show the plain link instead; when the form is there, CSS hides the link.
|
|
51
|
+
'<div class="pfs-footer__news">{{{subscribe}}}<a class="pfs-footer__sublink" href="https://pit.world/subscribe?site={{site}}">Newsletter</a></div>',
|
|
39
52
|
'<div class="pfs-footer__badges">',
|
|
40
53
|
'<a class="pfs-footer__badge" href="https://built.indie.pit.world/for?site={{site}}"><img src="https://built.indie.pit.world/for.svg?site={{site}}" alt="Listed on built.indie" width="200" height="44" loading="lazy"></a>',
|
|
41
54
|
'<a class="pfs-footer__badge pfs-footer__badge--pwamart" href="https://pwamart.com/for?site={{site}}"><img src="https://pwamart.com/for.svg?site={{site}}" alt="Get it on pwamart" width="200" height="44" loading="lazy"></a>',
|
|
55
|
+
// Per-site badges (the `badges` option, e.g. a game's Foundplay badge), built and escaped by render.js.
|
|
56
|
+
'{{{badges}}}',
|
|
42
57
|
'</div>',
|
|
43
58
|
'<p class="pfs-footer__copy">© {{years}} <a href="{{owner_url}}">{{owner_name}}</a>{{#tagline}} · {{tagline}}{{/tagline}}</p>',
|
|
44
59
|
'</div>',
|
package/template.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.3.1",
|
|
3
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__badges{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.6rem}.pfs-footer a.pfs-footer__badge{display:inline-block;line-height:0}.pfs-footer__badge img{display:block;width:200px;height:44px;max-width:100%}.pfs-footer__copy{margin:0;color:var(--pfs-footer-muted)}.pfs-footer__copy a{color:inherit}",
|
|
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__badges{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.6rem}.pfs-footer a.pfs-footer__badge{display:inline-block;line-height:0}.pfs-footer__badge img{display:block;width:200px;height:44px;max-width:100%}.pfs-footer__badge--site img{width:auto;height:auto}.pfs-footer__news{display:flex;justify-content:center}.pfs-footer__sub{display:flex;align-items:center;gap:.35rem;margin:0;flex-wrap:wrap;justify-content:center}.pfs-footer__sub input[type=email]{font:inherit;font-size:.8125rem;color:inherit;background:transparent;width:13.5rem;max-width:62vw;padding:.2rem .5rem;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:.35rem}.pfs-footer__sub input[type=email]::placeholder{color:var(--pfs-footer-muted);opacity:1}.pfs-footer__sub input[type=email]:focus-visible{outline:2px solid color-mix(in srgb,currentColor 45%,transparent);outline-offset:1px}.pfs-footer__sub button{font:inherit;font-size:.8125rem;color:var(--pfs-footer-muted);background:transparent;padding:.2rem .6rem;border:1px solid color-mix(in srgb,currentColor 25%,transparent);border-radius:.35rem;cursor:pointer}.pfs-footer__sub button:hover,.pfs-footer__sub button:focus-visible{color:inherit}.pfs-footer__hp{position:absolute!important;left:-9999px!important;width:1px;height:1px;opacity:0}.pfs-footer__sub+.pfs-footer__sublink{display:none}.pfs-footer__copy{margin:0;color:var(--pfs-footer-muted)}.pfs-footer__copy a{color:inherit}",
|
|
5
5
|
"link": "<a href=\"{{href}}\"{{#rel}} rel=\"{{rel}}\"{{/rel}}>{{label}}</a>",
|
|
6
|
-
"footer": "<footer class=\"pfs-footer\" data-pfs-footer=\"{{version}}\">{{#css}}<style{{#nonce}} nonce=\"{{nonce}}\"{{/nonce}}>{{{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\"><<</a><a href=\"{{ring}}\">{{ring_name}}</a><a href=\"{{next}}\" rel=\"next\" title=\"Next site\">>></a></nav><div class=\"pfs-footer__badges\"><a class=\"pfs-footer__badge\" href=\"https://built.indie.pit.world/for?site={{site}}\"><img src=\"https://built.indie.pit.world/for.svg?site={{site}}\" alt=\"Listed on built.indie\" width=\"200\" height=\"44\" loading=\"lazy\"></a><a class=\"pfs-footer__badge pfs-footer__badge--pwamart\" href=\"https://pwamart.com/for?site={{site}}\"><img src=\"https://pwamart.com/for.svg?site={{site}}\" alt=\"Get it on pwamart\" width=\"200\" height=\"44\" loading=\"lazy\"></a
|
|
6
|
+
"footer": "<footer class=\"pfs-footer\" data-pfs-footer=\"{{version}}\">{{#css}}<style{{#nonce}} nonce=\"{{nonce}}\"{{/nonce}}>{{{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\"><<</a><a href=\"{{ring}}\">{{ring_name}}</a><a href=\"{{next}}\" rel=\"next\" title=\"Next site\">>></a></nav><div class=\"pfs-footer__news\">{{{subscribe}}}<a class=\"pfs-footer__sublink\" href=\"https://pit.world/subscribe?site={{site}}\">Newsletter</a></div><div class=\"pfs-footer__badges\"><a class=\"pfs-footer__badge\" href=\"https://built.indie.pit.world/for?site={{site}}\"><img src=\"https://built.indie.pit.world/for.svg?site={{site}}\" alt=\"Listed on built.indie\" width=\"200\" height=\"44\" loading=\"lazy\"></a><a class=\"pfs-footer__badge pfs-footer__badge--pwamart\" href=\"https://pwamart.com/for?site={{site}}\"><img src=\"https://pwamart.com/for.svg?site={{site}}\" alt=\"Get it on pwamart\" width=\"200\" height=\"44\" loading=\"lazy\"></a>{{{badges}}}</div><p class=\"pfs-footer__copy\">© {{years}} <a href=\"{{owner_url}}\">{{owner_name}}</a>{{#tagline}} · {{tagline}}{{/tagline}}</p></div></footer>",
|
|
7
7
|
"houseLinks": []
|
|
8
8
|
}
|