@opnpress/opnpress-cli 0.2.4 → 0.3.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/dist/{build.js → cli/buildSite.js} +4 -4
- package/dist/{init.js → cli/initProject.js} +2 -2
- package/dist/{cli.js → cli/main.js} +2 -2
- package/dist/integrations/booking-calendar.js +23 -0
- package/dist/integrations/company-info.js +24 -0
- package/dist/integrations/contact-form.js +35 -0
- package/dist/integrations/contact-links.js +87 -0
- package/dist/integrations/index.js +18 -0
- package/dist/integrations/maps.js +23 -0
- package/dist/integrations/shareable-links.js +58 -0
- package/dist/integrations/socials-links.js +198 -0
- package/dist/integrations/video.js +74 -0
- package/dist/{linkEmbedding.js → renderer/aioHelper.js} +1 -1
- package/dist/renderer/footerHtmlBuilder.js +11 -0
- package/dist/renderer/headerHtmlBuilder.js +20 -0
- package/dist/renderer/mdBodyHtmlBuilder.js +117 -0
- package/dist/renderer/pageGenerator.js +204 -0
- package/dist/renderer/pageHelpers.js +132 -0
- package/dist/renderer/rawBodyHtmlBuilder.js +3 -0
- package/dist/{jsEmbedding.js → renderer/scriptBuilder.js} +2 -4
- package/dist/renderer/themeBuilder.js +723 -0
- package/dist/rendering/contact.js +149 -0
- package/dist/rendering/shared.js +75 -0
- package/dist/shortcodes/cardrow.js +85 -0
- package/dist/shortcodes/contact-card.js +24 -0
- package/dist/shortcodes/index.js +14 -0
- package/dist/shortcodes/js.js +7 -0
- package/dist/shortcodes/mailto.js +31 -0
- package/dist/shortcodes/pagelist.js +82 -0
- package/dist/shortcodes/tel.js +24 -0
- package/package.json +6 -6
- package/dist/render.js +0 -2041
- /package/dist/{server.js → cli/server.js} +0 -0
|
@@ -5,10 +5,10 @@ import { unified } from 'unified';
|
|
|
5
5
|
import remarkParse from 'remark-parse';
|
|
6
6
|
import remarkGfm from 'remark-gfm';
|
|
7
7
|
import YAML from 'yaml';
|
|
8
|
-
import { loadNavigationConfig, loadSiteConfig, loadThemeConfig } from '
|
|
9
|
-
import { buildPageTitle, isPublished, loadContentSources } from '
|
|
10
|
-
import { buildPageArtifact, writeRenderedPage } from '
|
|
11
|
-
import { escapeHtml, ensureDir, walkFiles, writeFileEnsured } from '
|
|
8
|
+
import { loadNavigationConfig, loadSiteConfig, loadThemeConfig } from '../config.js';
|
|
9
|
+
import { buildPageTitle, isPublished, loadContentSources } from '../content.js';
|
|
10
|
+
import { buildPageArtifact, writeRenderedPage } from '../renderer/pageGenerator.js';
|
|
11
|
+
import { escapeHtml, ensureDir, walkFiles, writeFileEnsured } from '../utils.js';
|
|
12
12
|
async function removeIfExists(target) {
|
|
13
13
|
await fs.rm(target, { recursive: true, force: true });
|
|
14
14
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { ensureDir, walkFiles, writeFileEnsured } from '
|
|
4
|
+
import { ensureDir, walkFiles, writeFileEnsured } from '../utils.js';
|
|
5
5
|
const INIT_MARKER_PATH = '.opnpress/initialized.json';
|
|
6
|
-
const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
6
|
+
const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'templates');
|
|
7
7
|
export async function isInitialized(rootDir) {
|
|
8
8
|
try {
|
|
9
9
|
await fs.access(path.join(rootDir, INIT_MARKER_PATH));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { buildSite } from './
|
|
4
|
-
import { initProject } from './
|
|
3
|
+
import { buildSite } from './buildSite.js';
|
|
4
|
+
import { initProject } from './initProject.js';
|
|
5
5
|
import { startPreviewServer } from './server.js';
|
|
6
6
|
function usage() {
|
|
7
7
|
return `Usage:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
import { sectionStyleFromConfig } from '../rendering/shared.js';
|
|
3
|
+
const handler = {
|
|
4
|
+
getTrigger: () => 'booking-calendar',
|
|
5
|
+
render: (config, { site, source }) => {
|
|
6
|
+
const title = typeof config.title === 'string' ? config.title : '';
|
|
7
|
+
const description = typeof config.description === 'string' ? config.description : '';
|
|
8
|
+
const integrationConfig = site.integrations.bookingCalendar;
|
|
9
|
+
if (!integrationConfig.embedUrl) {
|
|
10
|
+
throw new Error('booking-calendar block requires integrations.bookingCalendar.embedUrl to be set in site.config.yaml');
|
|
11
|
+
}
|
|
12
|
+
return `
|
|
13
|
+
<section class="card integration integration-booking-calendar"${sectionStyleFromConfig(config, source.routePath)}>
|
|
14
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
15
|
+
${description ? `<p>${escapeHtml(description)}</p>` : ''}
|
|
16
|
+
<div class="embed-frame">
|
|
17
|
+
<iframe src="${escapeHtml(integrationConfig.embedUrl)}" title="${escapeHtml(integrationConfig.buttonLabel)}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen></iframe>
|
|
18
|
+
</div>
|
|
19
|
+
</section>
|
|
20
|
+
`;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export default handler;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sectionStyleFromConfig } from '../rendering/shared.js';
|
|
2
|
+
import { renderContactCardMarkup } from '../rendering/contact.js';
|
|
3
|
+
const handler = {
|
|
4
|
+
getTrigger: () => 'company-info',
|
|
5
|
+
render: (config, { site, source }) => renderContactCardMarkup({
|
|
6
|
+
title: typeof config.title === 'string' ? config.title : '',
|
|
7
|
+
description: typeof config.description === 'string' ? config.description : '',
|
|
8
|
+
sectionStyle: sectionStyleFromConfig(config, source.routePath),
|
|
9
|
+
currentRoute: source.routePath,
|
|
10
|
+
className: 'company-info',
|
|
11
|
+
info: {
|
|
12
|
+
name: site.integrations.companyInfo.name ?? '',
|
|
13
|
+
tagline: site.integrations.companyInfo.tagline ?? '',
|
|
14
|
+
logo: site.integrations.companyInfo.logo ?? '',
|
|
15
|
+
address: site.integrations.companyInfo.address ?? '',
|
|
16
|
+
mapUrl: site.integrations.companyInfo.mapUrl ?? '',
|
|
17
|
+
phone: site.integrations.companyInfo.phone ?? '',
|
|
18
|
+
email: site.integrations.companyInfo.email ?? '',
|
|
19
|
+
hours: site.integrations.companyInfo.hours ?? '',
|
|
20
|
+
website: site.integrations.companyInfo.website ?? ''
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
};
|
|
24
|
+
export default handler;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
import { sectionStyleFromConfig } from '../rendering/shared.js';
|
|
3
|
+
const handler = {
|
|
4
|
+
getTrigger: () => 'contact-form',
|
|
5
|
+
render: (config, { site, source }) => {
|
|
6
|
+
const title = typeof config.title === 'string' ? config.title : '';
|
|
7
|
+
const description = typeof config.description === 'string' ? config.description : '';
|
|
8
|
+
const integrationConfig = site.integrations.contactForm;
|
|
9
|
+
if (!integrationConfig.action) {
|
|
10
|
+
throw new Error('contact-form block requires integrations.contactForm.action to be set in site.config.yaml');
|
|
11
|
+
}
|
|
12
|
+
return `
|
|
13
|
+
<section class="card integration integration-contact-form"${sectionStyleFromConfig(config, source.routePath)}>
|
|
14
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
15
|
+
${description ? `<p>${escapeHtml(description)}</p>` : ''}
|
|
16
|
+
<form class="integration-form" action="${escapeHtml(integrationConfig.action)}" method="post">
|
|
17
|
+
<label>
|
|
18
|
+
<span>Name</span>
|
|
19
|
+
<input name="name" type="text" autocomplete="name" required />
|
|
20
|
+
</label>
|
|
21
|
+
<label>
|
|
22
|
+
<span>Email</span>
|
|
23
|
+
<input name="email" type="email" autocomplete="email" required />
|
|
24
|
+
</label>
|
|
25
|
+
<label>
|
|
26
|
+
<span>Message</span>
|
|
27
|
+
<textarea name="message" rows="5" required></textarea>
|
|
28
|
+
</label>
|
|
29
|
+
<button type="submit">${escapeHtml(integrationConfig.buttonLabel)}</button>
|
|
30
|
+
</form>
|
|
31
|
+
</section>
|
|
32
|
+
`;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
export default handler;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
import { sectionStyleFromConfig } from '../rendering/shared.js';
|
|
3
|
+
import { normalizeContactHref } from '../rendering/contact.js';
|
|
4
|
+
function contactLinkIconMarkup(kind) {
|
|
5
|
+
switch (kind) {
|
|
6
|
+
case 'email':
|
|
7
|
+
return `<svg class="contact-link-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Zm0 4.236-8 5-8-5V6l8 5 8-5v2.236Z"/></svg>`;
|
|
8
|
+
case 'phone':
|
|
9
|
+
return `<svg class="contact-link-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M6.62 10.79a15.5 15.5 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.02-.24c1.12.37 2.33.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.31 21 3 13.69 3 4a1 1 0 0 1 1-1h2.49a1 1 0 0 1 1 1c0 1.24.2 2.45.57 3.57a1 1 0 0 1-.24 1.02l-2.2 2.2Z"/></svg>`;
|
|
10
|
+
case 'website':
|
|
11
|
+
return `<svg class="contact-link-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm7.9 9h-3.17a15.7 15.7 0 0 0-1.16-5.2A8.02 8.02 0 0 1 19.9 11Zm-1.8 2a8.03 8.03 0 0 1-4.33 4.2c.44-1.3.73-2.75.84-4.2h3.5ZM12 20c-.82 0-2.22-2.05-2.8-6h5.6c-.58 3.95-1.98 6-2.8 6Zm-2.8-8c.11-1.45.4-2.9.84-4.2A8.03 8.03 0 0 0 5.7 12h3.5ZM12 4c.82 0 2.22 2.05 2.8 6H9.2c.58-3.95 1.98-6 2.8-6Zm-3.77 1.8A15.7 15.7 0 0 0 7.07 11H3.9a8.02 8.02 0 0 1 4.33-5.2ZM3.9 13h3.17a15.7 15.7 0 0 0 1.16 5.2A8.02 8.02 0 0 1 3.9 13Zm12.77 5.2c.44-1.3.73-2.75.84-4.2h3.17a8.02 8.02 0 0 1-4.01 4.2Z"/></svg>`;
|
|
12
|
+
case 'map':
|
|
13
|
+
return `<svg class="contact-link-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 2C8.14 2 5 5.14 5 9c0 4.86 7 13 7 13s7-8.14 7-13c0-3.86-3.14-7-7-7Zm0 9.5A2.5 2.5 0 1 1 12 6a2.5 2.5 0 0 1 0 5.5Z"/></svg>`;
|
|
14
|
+
case 'address':
|
|
15
|
+
return `<svg class="contact-link-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3L18 7l-6 3.7L6 7l6-2.7ZM5 8.6l6 3.7v7.6l-6-3.3V8.6Zm14 0v7.9l-6 3.2v-7.6l6-3.5Z"/></svg>`;
|
|
16
|
+
case 'hours':
|
|
17
|
+
return `<svg class="contact-link-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 10.41V7h-2v6l5 3 .99-1.63L13 12.41Z"/></svg>`;
|
|
18
|
+
default:
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function buildContactLinkItems(data) {
|
|
23
|
+
const items = [];
|
|
24
|
+
const email = data.email?.trim();
|
|
25
|
+
const phone = data.phone?.trim();
|
|
26
|
+
const website = data.website?.trim();
|
|
27
|
+
const mapUrl = data.mapUrl?.trim();
|
|
28
|
+
const address = data.address?.trim();
|
|
29
|
+
const hours = data.hours?.trim();
|
|
30
|
+
if (email)
|
|
31
|
+
items.push({ kind: 'email', label: 'Email', href: normalizeContactHref(email, 'email') });
|
|
32
|
+
if (phone)
|
|
33
|
+
items.push({ kind: 'phone', label: 'Phone', href: normalizeContactHref(phone, 'phone') });
|
|
34
|
+
if (website)
|
|
35
|
+
items.push({ kind: 'website', label: 'Website', href: normalizeContactHref(website, 'website') });
|
|
36
|
+
if (mapUrl)
|
|
37
|
+
items.push({ kind: 'map', label: 'Map', href: normalizeContactHref(mapUrl, 'website') });
|
|
38
|
+
if (address)
|
|
39
|
+
items.push({ kind: 'address', label: 'Address', value: address });
|
|
40
|
+
if (hours)
|
|
41
|
+
items.push({ kind: 'hours', label: 'Hours', value: hours });
|
|
42
|
+
return items;
|
|
43
|
+
}
|
|
44
|
+
function renderContactLinksMarkup(items, options = {}) {
|
|
45
|
+
const showIcons = options.showIcons !== false;
|
|
46
|
+
const showLabels = options.showLabels !== false;
|
|
47
|
+
const variant = options.variant === 'stacked' ? 'stacked' : 'inline';
|
|
48
|
+
const content = items
|
|
49
|
+
.map((item) => {
|
|
50
|
+
const icon = showIcons ? contactLinkIconMarkup(item.kind) : '';
|
|
51
|
+
if (item.href) {
|
|
52
|
+
return `
|
|
53
|
+
<a class="share-link contact-link contact-link-${item.kind}" href="${escapeHtml(item.href)}" rel="noopener noreferrer" target="${item.href.startsWith('http') ? '_blank' : '_self'}"${showLabels ? '' : ` aria-label="${escapeHtml(item.label)}"`}>
|
|
54
|
+
${icon ? `<span class="contact-link-icon-wrap">${icon}</span>` : ''}
|
|
55
|
+
${showLabels ? `<span class="contact-link-label">${escapeHtml(item.label)}${item.value ? `: ${escapeHtml(item.value)}` : ''}</span>` : ''}
|
|
56
|
+
</a>
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
return `
|
|
60
|
+
<span class="share-link contact-link contact-link-${item.kind}">
|
|
61
|
+
${icon ? `<span class="contact-link-icon-wrap">${icon}</span>` : ''}
|
|
62
|
+
${showLabels ? `<span class="contact-link-label">${escapeHtml(item.label)}${item.value ? `: ${escapeHtml(item.value)}` : ''}</span>` : `<span class="contact-link-label">${escapeHtml(item.value ?? item.label)}</span>`}
|
|
63
|
+
</span>
|
|
64
|
+
`;
|
|
65
|
+
})
|
|
66
|
+
.join('');
|
|
67
|
+
return `<div class="integration-links contact-links contact-links-${variant}">${content}</div>`;
|
|
68
|
+
}
|
|
69
|
+
const handler = {
|
|
70
|
+
getTrigger: () => 'contact-links',
|
|
71
|
+
render: (config, { site, source }) => {
|
|
72
|
+
const title = typeof config.title === 'string' ? config.title : '';
|
|
73
|
+
const description = typeof config.description === 'string' ? config.description : '';
|
|
74
|
+
const variant = typeof config.variant === 'string' ? config.variant : 'inline';
|
|
75
|
+
const showIcons = typeof config.showIcons === 'boolean' ? config.showIcons : site.integrations.contactLinks.showIcons;
|
|
76
|
+
const showLabels = typeof config.showLabels === 'boolean' ? config.showLabels : site.integrations.contactLinks.showLabels;
|
|
77
|
+
const items = buildContactLinkItems(site.integrations.contactLinks);
|
|
78
|
+
return `
|
|
79
|
+
<section class="card integration integration-contact-links"${sectionStyleFromConfig(config, source.routePath)}>
|
|
80
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
81
|
+
${description ? `<p>${escapeHtml(description)}</p>` : ''}
|
|
82
|
+
${items.length ? renderContactLinksMarkup(items, { variant, showIcons, showLabels }) : '<p class="empty-state">No contact links configured.</p>'}
|
|
83
|
+
</section>
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
export default handler;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import contactForm from './contact-form.js';
|
|
2
|
+
import shareableLinks from './shareable-links.js';
|
|
3
|
+
import socialsLinks from './socials-links.js';
|
|
4
|
+
import companyInfo from './company-info.js';
|
|
5
|
+
import contactLinks from './contact-links.js';
|
|
6
|
+
import bookingCalendar from './booking-calendar.js';
|
|
7
|
+
import maps from './maps.js';
|
|
8
|
+
import video from './video.js';
|
|
9
|
+
export const integrationHandlers = new Map([
|
|
10
|
+
[contactForm.getTrigger(), contactForm],
|
|
11
|
+
[shareableLinks.getTrigger(), shareableLinks],
|
|
12
|
+
[socialsLinks.getTrigger(), socialsLinks],
|
|
13
|
+
[companyInfo.getTrigger(), companyInfo],
|
|
14
|
+
[contactLinks.getTrigger(), contactLinks],
|
|
15
|
+
[bookingCalendar.getTrigger(), bookingCalendar],
|
|
16
|
+
[maps.getTrigger(), maps],
|
|
17
|
+
[video.getTrigger(), video]
|
|
18
|
+
]);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
import { sectionStyleFromConfig } from '../rendering/shared.js';
|
|
3
|
+
const handler = {
|
|
4
|
+
getTrigger: () => 'maps',
|
|
5
|
+
render: (config, { site, source }) => {
|
|
6
|
+
const title = typeof config.title === 'string' ? config.title : '';
|
|
7
|
+
const description = typeof config.description === 'string' ? config.description : '';
|
|
8
|
+
const integrationConfig = site.integrations.maps;
|
|
9
|
+
if (!integrationConfig.embedUrl) {
|
|
10
|
+
throw new Error('maps block requires integrations.maps.embedUrl to be set in site.config.yaml');
|
|
11
|
+
}
|
|
12
|
+
return `
|
|
13
|
+
<section class="card integration integration-maps"${sectionStyleFromConfig(config, source.routePath)}>
|
|
14
|
+
${title ? `<h2>${escapeHtml(title)}</h2>` : ''}
|
|
15
|
+
${description ? `<p>${escapeHtml(description)}</p>` : ''}
|
|
16
|
+
<div class="embed-frame embed-frame--map">
|
|
17
|
+
<iframe src="${escapeHtml(integrationConfig.embedUrl)}" title="${escapeHtml(title || 'Map')}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen></iframe>
|
|
18
|
+
</div>
|
|
19
|
+
</section>
|
|
20
|
+
`;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export default handler;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
import { sectionStyleFromConfig } from '../rendering/shared.js';
|
|
3
|
+
const handler = {
|
|
4
|
+
getTrigger: () => 'shareable-links',
|
|
5
|
+
render: (config, { site, source, title, canonicalUrl }) => {
|
|
6
|
+
if (!site.integrations.shareableLinks.enabled) {
|
|
7
|
+
return '';
|
|
8
|
+
}
|
|
9
|
+
const shortcodeTitle = typeof config.title === 'string' ? config.title : '';
|
|
10
|
+
const shortcodeDescription = typeof config.description === 'string' ? config.description : '';
|
|
11
|
+
const encodedTitle = encodeURIComponent(title);
|
|
12
|
+
const encodedUrl = encodeURIComponent(canonicalUrl);
|
|
13
|
+
const requestedTargets = Array.isArray(config.targets) && config.targets.length ? config.targets : ['copy', 'x', 'linkedin', 'facebook', 'email'];
|
|
14
|
+
const targets = requestedTargets
|
|
15
|
+
.filter((target) => typeof target === 'string')
|
|
16
|
+
.map((target) => {
|
|
17
|
+
switch (target) {
|
|
18
|
+
case 'copy':
|
|
19
|
+
return { label: 'Copy link', href: canonicalUrl };
|
|
20
|
+
case 'x':
|
|
21
|
+
return { label: 'Share on X', href: `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}` };
|
|
22
|
+
case 'linkedin':
|
|
23
|
+
return { label: 'Share on LinkedIn', href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}` };
|
|
24
|
+
case 'facebook':
|
|
25
|
+
return { label: 'Share on Facebook', href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}` };
|
|
26
|
+
case 'email':
|
|
27
|
+
return { label: 'Email', href: `mailto:?subject=${encodedTitle}&body=${encodedUrl}` };
|
|
28
|
+
default:
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
.filter((target) => Boolean(target));
|
|
33
|
+
const fallbackTargets = [
|
|
34
|
+
{ label: 'Copy link', href: canonicalUrl },
|
|
35
|
+
{ label: 'Share on X', href: `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}` },
|
|
36
|
+
{ label: 'Share on LinkedIn', href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}` },
|
|
37
|
+
{ label: 'Share on Facebook', href: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}` },
|
|
38
|
+
{ label: 'Email', href: `mailto:?subject=${encodedTitle}&body=${encodedUrl}` }
|
|
39
|
+
];
|
|
40
|
+
const finalTargets = targets.length ? targets : fallbackTargets;
|
|
41
|
+
return `
|
|
42
|
+
<section class="card integration integration-shareable-links"${sectionStyleFromConfig(config, source.routePath)}>
|
|
43
|
+
${shortcodeTitle ? `<h2>${escapeHtml(shortcodeTitle)}</h2>` : ''}
|
|
44
|
+
${shortcodeDescription ? `<p>${escapeHtml(shortcodeDescription)}</p>` : ''}
|
|
45
|
+
<div class="integration-links">
|
|
46
|
+
${finalTargets
|
|
47
|
+
.map((target) => `
|
|
48
|
+
<a class="share-link" href="${escapeHtml(target.href)}" rel="noopener noreferrer" target="${target.href.startsWith('http') ? '_blank' : '_self'}">
|
|
49
|
+
${escapeHtml(target.label)}
|
|
50
|
+
</a>
|
|
51
|
+
`)
|
|
52
|
+
.join('')}
|
|
53
|
+
</div>
|
|
54
|
+
</section>
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
export default handler;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
import { isExternalUrl, sectionStyleFromConfig } from '../rendering/shared.js';
|
|
3
|
+
const SOCIAL_PROVIDER_ORDER = [
|
|
4
|
+
'twitter',
|
|
5
|
+
'x',
|
|
6
|
+
'github',
|
|
7
|
+
'facebook',
|
|
8
|
+
'instagram',
|
|
9
|
+
'linkedin',
|
|
10
|
+
'youtube',
|
|
11
|
+
'tiktok',
|
|
12
|
+
'threads',
|
|
13
|
+
'mastodon',
|
|
14
|
+
'bluesky',
|
|
15
|
+
'website',
|
|
16
|
+
'email'
|
|
17
|
+
];
|
|
18
|
+
function socialProviderLabel(provider) {
|
|
19
|
+
switch (provider) {
|
|
20
|
+
case 'x':
|
|
21
|
+
return 'X';
|
|
22
|
+
case 'youtube':
|
|
23
|
+
return 'YouTube';
|
|
24
|
+
case 'github':
|
|
25
|
+
return 'GitHub';
|
|
26
|
+
case 'linkedin':
|
|
27
|
+
return 'LinkedIn';
|
|
28
|
+
case 'mastodon':
|
|
29
|
+
return 'Mastodon';
|
|
30
|
+
case 'bluesky':
|
|
31
|
+
return 'Bluesky';
|
|
32
|
+
case 'facebook':
|
|
33
|
+
return 'Facebook';
|
|
34
|
+
case 'instagram':
|
|
35
|
+
return 'Instagram';
|
|
36
|
+
case 'tiktok':
|
|
37
|
+
return 'TikTok';
|
|
38
|
+
case 'threads':
|
|
39
|
+
return 'Threads';
|
|
40
|
+
case 'website':
|
|
41
|
+
return 'Website';
|
|
42
|
+
case 'email':
|
|
43
|
+
return 'Email';
|
|
44
|
+
default:
|
|
45
|
+
return 'Twitter';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function normalizeSocialInput(value) {
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
if (!trimmed) {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
if (isExternalUrl(trimmed) || trimmed.startsWith('mailto:')) {
|
|
54
|
+
return trimmed;
|
|
55
|
+
}
|
|
56
|
+
return trimmed.replace(/^@+/, '');
|
|
57
|
+
}
|
|
58
|
+
function socialHref(provider, value) {
|
|
59
|
+
const normalized = normalizeSocialInput(value);
|
|
60
|
+
if (!normalized) {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
if (isExternalUrl(normalized) || normalized.startsWith('mailto:')) {
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
const handle = normalized.replace(/^@+/, '');
|
|
67
|
+
switch (provider) {
|
|
68
|
+
case 'twitter':
|
|
69
|
+
case 'x':
|
|
70
|
+
return `https://x.com/${handle}`;
|
|
71
|
+
case 'github':
|
|
72
|
+
return `https://github.com/${handle}`;
|
|
73
|
+
case 'facebook':
|
|
74
|
+
return `https://facebook.com/${handle}`;
|
|
75
|
+
case 'instagram':
|
|
76
|
+
return `https://instagram.com/${handle}`;
|
|
77
|
+
case 'linkedin':
|
|
78
|
+
return `https://www.linkedin.com/in/${handle}`;
|
|
79
|
+
case 'youtube':
|
|
80
|
+
return `https://www.youtube.com/@${handle}`;
|
|
81
|
+
case 'tiktok':
|
|
82
|
+
return `https://www.tiktok.com/@${handle}`;
|
|
83
|
+
case 'threads':
|
|
84
|
+
return `https://www.threads.net/@${handle}`;
|
|
85
|
+
case 'mastodon':
|
|
86
|
+
if (handle.includes('@')) {
|
|
87
|
+
const [user, instance] = handle.split('@').filter(Boolean);
|
|
88
|
+
if (user && instance) {
|
|
89
|
+
return `https://${instance}/@${user}`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return `https://mastodon.social/@${handle}`;
|
|
93
|
+
case 'bluesky':
|
|
94
|
+
return `https://bsky.app/profile/${handle}`;
|
|
95
|
+
case 'website':
|
|
96
|
+
return `https://${handle}`;
|
|
97
|
+
case 'email':
|
|
98
|
+
return `mailto:${handle}`;
|
|
99
|
+
default:
|
|
100
|
+
return normalized;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function socialIconSlug(provider) {
|
|
104
|
+
switch (provider) {
|
|
105
|
+
case 'twitter':
|
|
106
|
+
return 'twitter';
|
|
107
|
+
case 'x':
|
|
108
|
+
return 'x';
|
|
109
|
+
case 'github':
|
|
110
|
+
return 'github';
|
|
111
|
+
case 'facebook':
|
|
112
|
+
return 'facebook';
|
|
113
|
+
case 'instagram':
|
|
114
|
+
return 'instagram';
|
|
115
|
+
case 'linkedin':
|
|
116
|
+
return 'linkedin';
|
|
117
|
+
case 'youtube':
|
|
118
|
+
return 'youtube';
|
|
119
|
+
case 'tiktok':
|
|
120
|
+
return 'tiktok';
|
|
121
|
+
case 'threads':
|
|
122
|
+
return 'threads';
|
|
123
|
+
case 'mastodon':
|
|
124
|
+
return 'mastodon';
|
|
125
|
+
case 'bluesky':
|
|
126
|
+
return 'bluesky';
|
|
127
|
+
default:
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function socialIconMarkup(provider) {
|
|
132
|
+
const slug = socialIconSlug(provider);
|
|
133
|
+
if (slug) {
|
|
134
|
+
const src = `https://cdn.simpleicons.org/${slug}?viewbox=auto&size=20`;
|
|
135
|
+
return `<img class="social-icon" src="${escapeHtml(src)}" alt="" aria-hidden="true" loading="lazy" decoding="async" />`;
|
|
136
|
+
}
|
|
137
|
+
switch (provider) {
|
|
138
|
+
case 'website':
|
|
139
|
+
return `<svg class="social-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm7.9 9h-3.17a15.7 15.7 0 0 0-1.16-5.2A8.02 8.02 0 0 1 19.9 11Zm-1.8 2a8.03 8.03 0 0 1-4.33 4.2c.44-1.3.73-2.75.84-4.2h3.5ZM12 20c-.82 0-2.22-2.05-2.8-6h5.6c-.58 3.95-1.98 6-2.8 6Zm-2.8-8c.11-1.45.4-2.9.84-4.2A8.03 8.03 0 0 0 5.7 12h3.5ZM12 4c.82 0 2.22 2.05 2.8 6H9.2c.58-3.95 1.98-6 2.8-6Zm-3.77 1.8A15.7 15.7 0 0 0 7.07 11H3.9a8.02 8.02 0 0 1 4.33-5.2ZM3.9 13h3.17a15.7 15.7 0 0 0 1.16 5.2A8.02 8.02 0 0 1 3.9 13Zm12.77 5.2c.44-1.3.73-2.75.84-4.2h3.17a8.02 8.02 0 0 1-4.01 4.2Z"/></svg>`;
|
|
140
|
+
case 'email':
|
|
141
|
+
return `<svg class="social-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Zm0 4.236-8 5-8-5V6l8 5 8-5v2.236Z"/></svg>`;
|
|
142
|
+
default:
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function socialEntriesFromConfig(socials, providers) {
|
|
147
|
+
const requestedProviders = providers?.length
|
|
148
|
+
? providers.map((provider) => provider.trim().toLowerCase()).filter(Boolean)
|
|
149
|
+
: SOCIAL_PROVIDER_ORDER;
|
|
150
|
+
const providerSet = new Set(requestedProviders);
|
|
151
|
+
const seenHrefs = new Set();
|
|
152
|
+
return SOCIAL_PROVIDER_ORDER.filter((provider) => providerSet.has(provider)).flatMap((provider) => {
|
|
153
|
+
const value = provider === 'twitter' || provider === 'x'
|
|
154
|
+
? socials.twitter ?? socials.x
|
|
155
|
+
: socials[provider];
|
|
156
|
+
if (!value || !value.trim()) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
const href = socialHref(provider, value);
|
|
160
|
+
if (!href || seenHrefs.has(href)) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
seenHrefs.add(href);
|
|
164
|
+
return [{ provider, label: socialProviderLabel(provider), href }];
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const handler = {
|
|
168
|
+
getTrigger: () => 'socials-links',
|
|
169
|
+
render: (config, { site, source }) => {
|
|
170
|
+
const shortcodeTitle = typeof config.title === 'string' ? config.title : '';
|
|
171
|
+
const shortcodeDescription = typeof config.description === 'string' ? config.description : '';
|
|
172
|
+
const providers = Array.isArray(config.providers)
|
|
173
|
+
? config.providers.filter((provider) => typeof provider === 'string')
|
|
174
|
+
: undefined;
|
|
175
|
+
const links = socialEntriesFromConfig(site.socials, providers);
|
|
176
|
+
const showLabels = typeof config.showLabels === 'boolean' ? config.showLabels : true;
|
|
177
|
+
return `
|
|
178
|
+
<section class="card integration integration-socials-links"${sectionStyleFromConfig(config, source.routePath)}>
|
|
179
|
+
${shortcodeTitle ? `<h2>${escapeHtml(shortcodeTitle)}</h2>` : ''}
|
|
180
|
+
${shortcodeDescription ? `<p>${escapeHtml(shortcodeDescription)}</p>` : ''}
|
|
181
|
+
<div class="integration-links">
|
|
182
|
+
${links
|
|
183
|
+
.map((link) => {
|
|
184
|
+
const icon = socialIconMarkup(link.provider);
|
|
185
|
+
return `
|
|
186
|
+
<a class="share-link social-link" href="${escapeHtml(link.href)}" rel="noopener noreferrer" target="_blank"${showLabels ? '' : ` aria-label="${escapeHtml(link.label)}"`}>
|
|
187
|
+
${icon ? `<span class="social-link-icon">${icon}</span>` : ''}
|
|
188
|
+
${showLabels ? `<span class="social-link-label">${escapeHtml(link.label)}</span>` : ''}
|
|
189
|
+
</a>
|
|
190
|
+
`;
|
|
191
|
+
})
|
|
192
|
+
.join('')}
|
|
193
|
+
</div>
|
|
194
|
+
</section>
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
export default handler;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
import { isExternalUrl, sectionStyleFromConfig } from '../rendering/shared.js';
|
|
3
|
+
function buildVideoEmbedUrl(provider, value) {
|
|
4
|
+
const raw = value?.trim() ?? '';
|
|
5
|
+
if (!raw) {
|
|
6
|
+
return '';
|
|
7
|
+
}
|
|
8
|
+
if (isExternalUrl(raw) && /\/embed\//i.test(raw)) {
|
|
9
|
+
return raw;
|
|
10
|
+
}
|
|
11
|
+
const normalizedProvider = (provider ?? 'youtube').trim().toLowerCase();
|
|
12
|
+
const trimmed = raw.replace(/^https?:\/\//i, '');
|
|
13
|
+
if (isExternalUrl(raw)) {
|
|
14
|
+
if (/\/embed\//i.test(raw)) {
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
const parsed = new URL(raw);
|
|
18
|
+
const pathParts = parsed.pathname.split('/').filter(Boolean);
|
|
19
|
+
const lastPart = pathParts[pathParts.length - 1] ?? '';
|
|
20
|
+
if (normalizedProvider === 'youtube' || normalizedProvider === 'x') {
|
|
21
|
+
const videoId = parsed.searchParams.get('v') ??
|
|
22
|
+
parsed.searchParams.get('video_id') ??
|
|
23
|
+
lastPart.replace(/^@/, '');
|
|
24
|
+
return videoId ? `https://www.youtube.com/embed/${videoId}` : raw;
|
|
25
|
+
}
|
|
26
|
+
if (normalizedProvider === 'vimeo') {
|
|
27
|
+
const id = pathParts.find((part) => /^\d+$/.test(part)) ?? lastPart;
|
|
28
|
+
return id ? `https://player.vimeo.com/video/${id}` : raw;
|
|
29
|
+
}
|
|
30
|
+
if (normalizedProvider === 'loom') {
|
|
31
|
+
const slug = lastPart || parsed.pathname.replace(/^\//, '');
|
|
32
|
+
return slug ? `https://www.loom.com/embed/${slug}` : raw;
|
|
33
|
+
}
|
|
34
|
+
return raw;
|
|
35
|
+
}
|
|
36
|
+
const clean = trimmed.replace(/^@+/, '');
|
|
37
|
+
switch (normalizedProvider) {
|
|
38
|
+
case 'youtube':
|
|
39
|
+
return `https://www.youtube.com/embed/${clean}`;
|
|
40
|
+
case 'vimeo':
|
|
41
|
+
return `https://player.vimeo.com/video/${clean}`;
|
|
42
|
+
case 'loom':
|
|
43
|
+
return `https://www.loom.com/embed/${clean}`;
|
|
44
|
+
default:
|
|
45
|
+
return clean;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const handler = {
|
|
49
|
+
getTrigger: () => 'video',
|
|
50
|
+
render: (config, { source }) => {
|
|
51
|
+
const shortcodeTitle = typeof config.title === 'string' ? config.title : '';
|
|
52
|
+
const shortcodeDescription = typeof config.description === 'string' ? config.description : '';
|
|
53
|
+
const videoProvider = typeof config.provider === 'string' ? config.provider : 'youtube';
|
|
54
|
+
const videoUrl = typeof config.url === 'string'
|
|
55
|
+
? config.url
|
|
56
|
+
: typeof config.embedUrl === 'string'
|
|
57
|
+
? config.embedUrl
|
|
58
|
+
: '';
|
|
59
|
+
const embedUrl = buildVideoEmbedUrl(videoProvider, videoUrl);
|
|
60
|
+
if (!embedUrl) {
|
|
61
|
+
throw new Error('video block requires provider and url parameters in the shortcode body');
|
|
62
|
+
}
|
|
63
|
+
return `
|
|
64
|
+
<section class="card integration integration-video"${sectionStyleFromConfig(config, source.routePath)}>
|
|
65
|
+
${shortcodeTitle ? `<h2>${escapeHtml(shortcodeTitle)}</h2>` : ''}
|
|
66
|
+
${shortcodeDescription ? `<p>${escapeHtml(shortcodeDescription)}</p>` : ''}
|
|
67
|
+
<div class="embed-frame">
|
|
68
|
+
<iframe src="${escapeHtml(embedUrl)}" title="${escapeHtml(shortcodeTitle || 'Video')}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen></iframe>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
export default handler;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
export function buildFooterHtml(params) {
|
|
3
|
+
const { siteDescription, poweredBy, footerHtml, discoveryFooterNav } = params;
|
|
4
|
+
return `
|
|
5
|
+
<footer class="site-footer">
|
|
6
|
+
<div>${escapeHtml(siteDescription)}</div>
|
|
7
|
+
<nav class="site-footer-nav" aria-label="Footer navigation">${poweredBy}${footerHtml}</nav>
|
|
8
|
+
${discoveryFooterNav}
|
|
9
|
+
</footer>
|
|
10
|
+
`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { escapeHtml } from '../utils.js';
|
|
2
|
+
export function buildHeaderHtml(params) {
|
|
3
|
+
const { siteName, homeHref, navigationHtml, discoveryHeaderNav, logoUrl } = params;
|
|
4
|
+
return `
|
|
5
|
+
<header class="site-header">
|
|
6
|
+
<a class="site-brand" href="${escapeHtml(homeHref)}">
|
|
7
|
+
${logoUrl ? `<img class="site-logo" src="${escapeHtml(logoUrl)}" alt="" aria-hidden="true" />` : ''}
|
|
8
|
+
<span>${escapeHtml(siteName)}</span>
|
|
9
|
+
</a>
|
|
10
|
+
<nav class="site-nav" aria-label="Main navigation">${navigationHtml}</nav>
|
|
11
|
+
${discoveryHeaderNav}
|
|
12
|
+
</header>
|
|
13
|
+
`;
|
|
14
|
+
}
|
|
15
|
+
export function renderNavList(items) {
|
|
16
|
+
if (!items.length) {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
return items.map((item) => `<a class="nav-link" href="${escapeHtml(item.url)}">${escapeHtml(item.title)}</a>`).join('');
|
|
20
|
+
}
|