@opnpress/opnpress-cli 0.1.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/README.md +72 -0
- package/dist/build.js +518 -0
- package/dist/cli.js +68 -0
- package/dist/config.js +210 -0
- package/dist/content.js +109 -0
- package/dist/init.js +45 -0
- package/dist/render.js +1975 -0
- package/dist/server.js +97 -0
- package/dist/utils.js +45 -0
- package/package.json +51 -0
- package/templates/.github/workflows/build-pages.yml +37 -0
- package/templates/.skills/README.md +6 -0
- package/templates/.skills/add-shortcode.md +18 -0
- package/templates/.skills/create-page.md +17 -0
- package/templates/.skills/deployment-checks.md +75 -0
- package/templates/.skills/integrations.md +6 -0
- package/templates/.skills/link-audit.md +17 -0
- package/templates/.skills/setup-integrations.md +84 -0
- package/templates/.skills/shortcodes.md +152 -0
- package/templates/.skills/site-audit.md +20 -0
- package/templates/.skills/theme-customization.md +17 -0
- package/templates/.skills/update-header-footer.md +15 -0
- package/templates/.skills/update-navigation.md +16 -0
- package/templates/.skills/update-page.md +16 -0
- package/templates/README.md +32 -0
- package/templates/content/pages/index.md +8 -0
- package/templates/navigation.yaml +20 -0
- package/templates/site.config.yaml +80 -0
- package/templates/theme.config.yaml +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# OpnPressCli
|
|
2
|
+
|
|
3
|
+
`opnPress` is the CLI for creating, building, and previewing OpnPress sites.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
- `opnPrs init` or `opnPress init` initializes the current folder.
|
|
8
|
+
- `opnPrs build` or `opnPress build` builds the site.
|
|
9
|
+
- `opnPrs run` or `opnPress run` builds, starts a local server, and prints the preview URL.
|
|
10
|
+
|
|
11
|
+
## Scaffold Templates
|
|
12
|
+
|
|
13
|
+
`opnPress init` copies human-readable templates from [`templates/`](/mnt/d/Dev/OpnPressCli/templates) into the current folder.
|
|
14
|
+
|
|
15
|
+
Edit those files here to change what new sites receive by default:
|
|
16
|
+
|
|
17
|
+
- [`templates/README.md`](/mnt/d/Dev/OpnPressCli/templates/README.md)
|
|
18
|
+
- [`templates/site.config.yaml`](/mnt/d/Dev/OpnPressCli/templates/site.config.yaml)
|
|
19
|
+
- [`templates/theme.config.yaml`](/mnt/d/Dev/OpnPressCli/templates/theme.config.yaml)
|
|
20
|
+
- [`templates/navigation.yaml`](/mnt/d/Dev/OpnPressCli/templates/navigation.yaml)
|
|
21
|
+
- [`templates/content/pages/index.md`](/mnt/d/Dev/OpnPressCli/templates/content/pages/index.md)
|
|
22
|
+
- [`templates/.skills/`](/mnt/d/Dev/OpnPressCli/templates/.skills)
|
|
23
|
+
- [`templates/.github/workflows/build-pages.yml`](/mnt/d/Dev/OpnPressCli/templates/.github/workflows/build-pages.yml)
|
|
24
|
+
|
|
25
|
+
The generated workflow deploys to Cloudflare Pages and expects these repository secrets:
|
|
26
|
+
|
|
27
|
+
- `CLOUDFLARE_API_TOKEN`
|
|
28
|
+
- `CLOUDFLARE_ACCOUNT_ID`
|
|
29
|
+
- `CLOUDFLARE_PROJECT_NAME`
|
|
30
|
+
|
|
31
|
+
## Local Use
|
|
32
|
+
|
|
33
|
+
From a site checkout, run:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install
|
|
37
|
+
npm exec opnPress -- init
|
|
38
|
+
npm exec opnPress -- run
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The CLI expects the site content and config files in the current working directory.
|
|
42
|
+
|
|
43
|
+
## Publishing
|
|
44
|
+
|
|
45
|
+
To publish the CLI, you need:
|
|
46
|
+
|
|
47
|
+
- an npm account that belongs to the `opnpress` organization and can publish `@opnpress/opnpress-cli`
|
|
48
|
+
- a GitHub account with write access to this repository
|
|
49
|
+
|
|
50
|
+
The release workflow in [`.github/workflows/release.yml`](/mnt/d/Dev/OpnPressCli/.github/workflows/release.yml) publishes on version tags like `v0.1.1`.
|
|
51
|
+
|
|
52
|
+
Release flow:
|
|
53
|
+
|
|
54
|
+
1. Create the `opnpress` npm organization in the npm UI if it does not already exist.
|
|
55
|
+
2. Publish the package once from your account if npm needs the package page created before trusted publishing can be enabled.
|
|
56
|
+
3. In npm package settings, add a trusted publisher for:
|
|
57
|
+
- GitHub organization or user: your GitHub account or the `OpnPress` org
|
|
58
|
+
- Repository: `OpnPressCli`
|
|
59
|
+
- Workflow file: `.github/workflows/release.yml`
|
|
60
|
+
4. Merge the change you want to ship.
|
|
61
|
+
5. Update `package.json` version.
|
|
62
|
+
6. Create and push a tag such as `v0.1.1`.
|
|
63
|
+
7. GitHub Actions runs build, test, and `npm publish --access public`.
|
|
64
|
+
8. npm generates provenance automatically and GitHub creates a release for the tag.
|
|
65
|
+
|
|
66
|
+
The package is org-scoped and public, so `npm` treats each `name + version` combination as permanent once published.
|
|
67
|
+
|
|
68
|
+
## Sample Site
|
|
69
|
+
|
|
70
|
+
See the current sample site in [OpnPress](https://github.com/OpnPress/OpnPress).
|
|
71
|
+
|
|
72
|
+
The live sample site is published at [opnpress.com](https://opnpress.com/).
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { unified } from 'unified';
|
|
4
|
+
import remarkParse from 'remark-parse';
|
|
5
|
+
import remarkGfm from 'remark-gfm';
|
|
6
|
+
import YAML from 'yaml';
|
|
7
|
+
import { loadNavigationConfig, loadSiteConfig, loadThemeConfig } from './config.js';
|
|
8
|
+
import { buildPageTitle, isPublished, loadContentSources } from './content.js';
|
|
9
|
+
import { buildPageArtifact, writeRenderedPage } from './render.js';
|
|
10
|
+
import { escapeHtml, ensureDir, writeFileEnsured } from './utils.js';
|
|
11
|
+
async function removeIfExists(target) {
|
|
12
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
async function copyPublicAssets(rootDir, distDir) {
|
|
15
|
+
const publicDir = path.join(rootDir, 'public');
|
|
16
|
+
try {
|
|
17
|
+
await fs.access(publicDir);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await fs.cp(publicDir, distDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
function getSiteBaseUrl(domain) {
|
|
25
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(domain)) {
|
|
26
|
+
return domain.endsWith('/') ? domain : `${domain}/`;
|
|
27
|
+
}
|
|
28
|
+
return `https://${domain.replace(/\/+$/, '')}/`;
|
|
29
|
+
}
|
|
30
|
+
function isExternalUrl(url) {
|
|
31
|
+
return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url) || url.startsWith('//');
|
|
32
|
+
}
|
|
33
|
+
function isIgnoredLink(url) {
|
|
34
|
+
return url.startsWith('#') || url.startsWith('mailto:') || url.startsWith('tel:') || url.startsWith('javascript:');
|
|
35
|
+
}
|
|
36
|
+
function isStaticAssetPath(pathname) {
|
|
37
|
+
return /\.(png|jpe?g|gif|webp|svg|ico|css|js|json|xml|txt)$/i.test(pathname);
|
|
38
|
+
}
|
|
39
|
+
function isSourceMirrorPath(pathname) {
|
|
40
|
+
return pathname === '/source.md' || pathname === '/source.html' || pathname.endsWith('/source.md') || pathname.endsWith('/source.html');
|
|
41
|
+
}
|
|
42
|
+
function isGeneratedDocPath(pathname) {
|
|
43
|
+
return pathname === '/fullSiteContent.md' || pathname.endsWith('/fullSiteContent.md');
|
|
44
|
+
}
|
|
45
|
+
function extractLinks(html) {
|
|
46
|
+
const links = [];
|
|
47
|
+
const pattern = /href="([^"]+)"/gi;
|
|
48
|
+
let match;
|
|
49
|
+
while ((match = pattern.exec(html))) {
|
|
50
|
+
links.push(match[1]);
|
|
51
|
+
}
|
|
52
|
+
return links;
|
|
53
|
+
}
|
|
54
|
+
function routeToOutputPath(distDir, routePath, filename) {
|
|
55
|
+
if (routePath === '/') {
|
|
56
|
+
return path.join(distDir, filename);
|
|
57
|
+
}
|
|
58
|
+
return path.join(distDir, routePath.replace(/^\//, ''), filename);
|
|
59
|
+
}
|
|
60
|
+
function pageJsonToText(pageJson) {
|
|
61
|
+
return JSON.stringify(pageJson, null, 2) + '\n';
|
|
62
|
+
}
|
|
63
|
+
function stripHtmlMarkup(input) {
|
|
64
|
+
return input
|
|
65
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
66
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
67
|
+
.replace(/<[^>]+>/g, ' ')
|
|
68
|
+
.replace(/\s+/g, ' ')
|
|
69
|
+
.trim();
|
|
70
|
+
}
|
|
71
|
+
async function readOptionalTextFile(rootDir, relativePath) {
|
|
72
|
+
const filePath = path.join(rootDir, relativePath);
|
|
73
|
+
try {
|
|
74
|
+
await fs.access(filePath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return fs.readFile(filePath, 'utf8');
|
|
80
|
+
}
|
|
81
|
+
function normalizeBlockText(input) {
|
|
82
|
+
return input.replace(/\s+/g, ' ').trim();
|
|
83
|
+
}
|
|
84
|
+
function appendUniqueBlocks(target, blocks, seen) {
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
const trimmed = block.trim();
|
|
87
|
+
const normalized = normalizeBlockText(trimmed);
|
|
88
|
+
if (!normalized || seen.has(normalized)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
seen.add(normalized);
|
|
92
|
+
target.push(trimmed);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function appendPageReference(target, title, routePath) {
|
|
96
|
+
target.push('---');
|
|
97
|
+
target.push(`From: [${title}](${routePath})`);
|
|
98
|
+
}
|
|
99
|
+
function escapeMarkdownLinkText(value) {
|
|
100
|
+
return value.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]');
|
|
101
|
+
}
|
|
102
|
+
function normalizePhoneHref(value) {
|
|
103
|
+
return value.trim().startsWith('tel:')
|
|
104
|
+
? value.trim()
|
|
105
|
+
: `tel:${value.trim().replace(/[^\d+*#.,]/g, '')}`;
|
|
106
|
+
}
|
|
107
|
+
function formatClickLabel(value, href, fallback = value) {
|
|
108
|
+
const label = href.startsWith('http://') || href.startsWith('https://') ? value : fallback;
|
|
109
|
+
return `[${escapeMarkdownLinkText(label)}](${href})`;
|
|
110
|
+
}
|
|
111
|
+
function formatContactFieldLabel(kind, value) {
|
|
112
|
+
if (kind === 'email') {
|
|
113
|
+
return `[${escapeMarkdownLinkText(value)}](mailto:${value.replace(/^mailto:/i, '')})`;
|
|
114
|
+
}
|
|
115
|
+
if (kind === 'phone') {
|
|
116
|
+
const href = normalizePhoneHref(value);
|
|
117
|
+
return `[${escapeMarkdownLinkText(value)}](${href})`;
|
|
118
|
+
}
|
|
119
|
+
const trimmed = value.trim();
|
|
120
|
+
if (!trimmed) {
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
if (/^(?:\.{1,2}\/|\/)/.test(trimmed)) {
|
|
124
|
+
return `[Goto Page](${trimmed})`;
|
|
125
|
+
}
|
|
126
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)) {
|
|
127
|
+
return formatClickLabel(trimmed, trimmed, trimmed);
|
|
128
|
+
}
|
|
129
|
+
const href = isExternalUrl(trimmed) ? trimmed : `https://${trimmed}`;
|
|
130
|
+
return formatClickLabel(trimmed, href, trimmed);
|
|
131
|
+
}
|
|
132
|
+
function parseShortcodeConfig(body) {
|
|
133
|
+
const trimmed = body.trim();
|
|
134
|
+
if (!trimmed) {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const parsed = YAML.parse(trimmed);
|
|
139
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
140
|
+
return parsed;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Fall through to an empty config.
|
|
145
|
+
}
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
function extractShortcodeText(body) {
|
|
149
|
+
const lines = body
|
|
150
|
+
.split('\n')
|
|
151
|
+
.map((line) => line.trim())
|
|
152
|
+
.filter((line) => line.length > 0);
|
|
153
|
+
if (!lines.length) {
|
|
154
|
+
return { title: '', description: '' };
|
|
155
|
+
}
|
|
156
|
+
const [title, ...rest] = lines;
|
|
157
|
+
return {
|
|
158
|
+
title,
|
|
159
|
+
description: rest.join(' ')
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function inlineTextFromNode(node) {
|
|
163
|
+
if (!node || typeof node !== 'object') {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
switch (node.type) {
|
|
167
|
+
case 'text':
|
|
168
|
+
case 'inlineCode':
|
|
169
|
+
return String(node.value ?? '');
|
|
170
|
+
case 'image':
|
|
171
|
+
return String(node.alt ?? node.title ?? '');
|
|
172
|
+
case 'link':
|
|
173
|
+
case 'strong':
|
|
174
|
+
case 'emphasis':
|
|
175
|
+
case 'delete':
|
|
176
|
+
case 'paragraph':
|
|
177
|
+
case 'heading':
|
|
178
|
+
case 'tableCell':
|
|
179
|
+
case 'tableRow':
|
|
180
|
+
case 'root':
|
|
181
|
+
return Array.isArray(node.children) ? node.children.map(inlineTextFromNode).join(' ') : '';
|
|
182
|
+
case 'break':
|
|
183
|
+
return ' ';
|
|
184
|
+
case 'html':
|
|
185
|
+
return stripHtmlMarkup(String(node.value ?? ''));
|
|
186
|
+
default:
|
|
187
|
+
return Array.isArray(node.children) ? node.children.map(inlineTextFromNode).join(' ') : String(node.value ?? '');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function markdownNodeToBlocks(node) {
|
|
191
|
+
if (!node || typeof node !== 'object') {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
switch (node.type) {
|
|
195
|
+
case 'root':
|
|
196
|
+
return (Array.isArray(node.children) ? node.children : []).flatMap((child) => markdownNodeToBlocks(child));
|
|
197
|
+
case 'heading': {
|
|
198
|
+
const depth = Math.max(1, Math.min(6, Number(node.depth ?? 1)));
|
|
199
|
+
const text = inlineTextFromNode(node);
|
|
200
|
+
return text ? [`${'#'.repeat(depth)} ${text}`] : [];
|
|
201
|
+
}
|
|
202
|
+
case 'paragraph': {
|
|
203
|
+
const text = inlineTextFromNode(node);
|
|
204
|
+
return text ? [text] : [];
|
|
205
|
+
}
|
|
206
|
+
case 'list': {
|
|
207
|
+
const ordered = Boolean(node.ordered);
|
|
208
|
+
const start = Number(node.start ?? 1);
|
|
209
|
+
const items = Array.isArray(node.children) ? node.children : [];
|
|
210
|
+
const blocks = [];
|
|
211
|
+
items.forEach((item, index) => {
|
|
212
|
+
const marker = ordered ? `${start + index}.` : '-';
|
|
213
|
+
const itemText = inlineTextFromNode(item).trim();
|
|
214
|
+
if (itemText) {
|
|
215
|
+
blocks.push(`${marker} ${itemText}`);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return blocks;
|
|
219
|
+
}
|
|
220
|
+
case 'blockquote': {
|
|
221
|
+
const text = inlineTextFromNode(node);
|
|
222
|
+
return text ? [`> ${text}`] : [];
|
|
223
|
+
}
|
|
224
|
+
case 'code': {
|
|
225
|
+
const lang = typeof node.lang === 'string' && node.lang.trim() ? node.lang.trim() : '';
|
|
226
|
+
const value = String(node.value ?? '').trimEnd();
|
|
227
|
+
return value ? [`\`\`\`${lang}\n${value}\n\`\`\``] : [];
|
|
228
|
+
}
|
|
229
|
+
case 'table': {
|
|
230
|
+
const rows = Array.isArray(node.children) ? node.children : [];
|
|
231
|
+
const blocks = rows
|
|
232
|
+
.map((row) => {
|
|
233
|
+
const cells = Array.isArray(row.children)
|
|
234
|
+
? row.children.map((cell) => normalizeBlockText(inlineTextFromNode(cell)))
|
|
235
|
+
: [];
|
|
236
|
+
return cells.filter(Boolean).length ? `| ${cells.filter(Boolean).join(' | ')} |` : '';
|
|
237
|
+
})
|
|
238
|
+
.filter(Boolean);
|
|
239
|
+
return blocks;
|
|
240
|
+
}
|
|
241
|
+
case 'thematicBreak':
|
|
242
|
+
return ['---'];
|
|
243
|
+
case 'html': {
|
|
244
|
+
const text = stripHtmlMarkup(String(node.value ?? ''));
|
|
245
|
+
return text ? [text] : [];
|
|
246
|
+
}
|
|
247
|
+
default:
|
|
248
|
+
return Array.isArray(node.children) ? node.children.flatMap((child) => markdownNodeToBlocks(child)) : [];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function markdownToBlocks(markdown) {
|
|
252
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(markdown);
|
|
253
|
+
return markdownNodeToBlocks(tree);
|
|
254
|
+
}
|
|
255
|
+
function summarizeShortcodeBlock(blockName, body, site) {
|
|
256
|
+
const config = parseShortcodeConfig(body);
|
|
257
|
+
const title = typeof config.title === 'string' ? config.title : '';
|
|
258
|
+
const description = typeof config.description === 'string' ? config.description : '';
|
|
259
|
+
switch (blockName) {
|
|
260
|
+
case 'cardrow':
|
|
261
|
+
case 'pagelist':
|
|
262
|
+
case 'shareable-links':
|
|
263
|
+
return [title, description].filter(Boolean);
|
|
264
|
+
case 'contact-card':
|
|
265
|
+
case 'company-info': {
|
|
266
|
+
const name = typeof config.name === 'string' ? config.name : '';
|
|
267
|
+
const tagline = typeof config.tagline === 'string' ? config.tagline : '';
|
|
268
|
+
const address = typeof config.address === 'string' ? config.address : '';
|
|
269
|
+
const hours = typeof config.hours === 'string' ? config.hours : '';
|
|
270
|
+
const website = typeof config.website === 'string' ? config.website : '';
|
|
271
|
+
const email = typeof config.email === 'string' ? config.email : '';
|
|
272
|
+
const phone = typeof config.phone === 'string' ? config.phone : '';
|
|
273
|
+
return [
|
|
274
|
+
name ? (tagline ? `${name} — ${tagline}` : name) : tagline,
|
|
275
|
+
address ? `Address: ${address}` : '',
|
|
276
|
+
hours ? `Hours: ${hours}` : '',
|
|
277
|
+
website ? `Website: ${formatContactFieldLabel('website', website)}` : '',
|
|
278
|
+
email ? `Email: ${formatContactFieldLabel('email', email)}` : '',
|
|
279
|
+
phone ? `Phone: ${formatContactFieldLabel('phone', phone)}` : ''
|
|
280
|
+
].filter(Boolean);
|
|
281
|
+
}
|
|
282
|
+
case 'contact-links': {
|
|
283
|
+
const contact = site.integrations.contactLinks;
|
|
284
|
+
return [
|
|
285
|
+
contact.email ? `Email: ${formatContactFieldLabel('email', contact.email)}` : '',
|
|
286
|
+
contact.phone ? `Phone: ${formatContactFieldLabel('phone', contact.phone)}` : '',
|
|
287
|
+
contact.website ? `Website: ${formatContactFieldLabel('website', contact.website)}` : '',
|
|
288
|
+
contact.address ? `Address: ${contact.address}` : '',
|
|
289
|
+
contact.hours ? `Hours: ${contact.hours}` : ''
|
|
290
|
+
].filter(Boolean);
|
|
291
|
+
}
|
|
292
|
+
case 'mailto': {
|
|
293
|
+
const email = typeof config.email === 'string' ? config.email : '';
|
|
294
|
+
return [email ? `Email: ${formatContactFieldLabel('email', email)}` : '', description].filter(Boolean);
|
|
295
|
+
}
|
|
296
|
+
case 'tel': {
|
|
297
|
+
const phone = typeof config.phone === 'string' ? config.phone : '';
|
|
298
|
+
return [phone ? `Phone: ${formatContactFieldLabel('phone', phone)}` : '', description].filter(Boolean);
|
|
299
|
+
}
|
|
300
|
+
case 'socials-links': {
|
|
301
|
+
const providers = Array.isArray(config.providers)
|
|
302
|
+
? config.providers.filter((provider) => typeof provider === 'string')
|
|
303
|
+
: undefined;
|
|
304
|
+
const chosenProviders = providers?.length
|
|
305
|
+
? providers.map((provider) => provider.trim().toLowerCase()).filter(Boolean)
|
|
306
|
+
: ['twitter', 'x', 'github', 'facebook', 'instagram', 'linkedin', 'youtube', 'tiktok', 'threads', 'mastodon', 'bluesky', 'website', 'email'];
|
|
307
|
+
const orderedProviders = ['twitter', 'x', 'github', 'facebook', 'instagram', 'linkedin', 'youtube', 'tiktok', 'threads', 'mastodon', 'bluesky', 'website', 'email'];
|
|
308
|
+
const blocks = [];
|
|
309
|
+
for (const provider of orderedProviders) {
|
|
310
|
+
if (!chosenProviders.includes(provider)) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const value = provider === 'twitter' || provider === 'x'
|
|
314
|
+
? site.socials.twitter ?? site.socials.x
|
|
315
|
+
: site.socials[provider];
|
|
316
|
+
if (!value || !value.trim()) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
blocks.push(`${provider === 'x' ? 'X' : provider.charAt(0).toUpperCase() + provider.slice(1)}: ${value.trim()}`);
|
|
320
|
+
}
|
|
321
|
+
return blocks;
|
|
322
|
+
}
|
|
323
|
+
case 'booking-calendar':
|
|
324
|
+
case 'maps':
|
|
325
|
+
case 'video':
|
|
326
|
+
return [title, description].filter(Boolean);
|
|
327
|
+
default:
|
|
328
|
+
return [title, description].filter(Boolean);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function markdownSourceToBlocks(markdown, site) {
|
|
332
|
+
const blockPattern = /:::([a-z-]+)\n([\s\S]*?)\n:::/g;
|
|
333
|
+
const blocks = [];
|
|
334
|
+
let lastIndex = 0;
|
|
335
|
+
let match;
|
|
336
|
+
while ((match = blockPattern.exec(markdown))) {
|
|
337
|
+
const textChunk = markdown.slice(lastIndex, match.index);
|
|
338
|
+
if (textChunk.trim()) {
|
|
339
|
+
blocks.push(...markdownToBlocks(textChunk));
|
|
340
|
+
}
|
|
341
|
+
blocks.push(...summarizeShortcodeBlock(match[1], match[2], site));
|
|
342
|
+
lastIndex = match.index + match[0].length;
|
|
343
|
+
}
|
|
344
|
+
const tail = markdown.slice(lastIndex);
|
|
345
|
+
if (tail.trim()) {
|
|
346
|
+
blocks.push(...markdownToBlocks(tail));
|
|
347
|
+
}
|
|
348
|
+
return blocks;
|
|
349
|
+
}
|
|
350
|
+
function sitemapXmlFromPages(siteDomain, pages) {
|
|
351
|
+
const baseUrl = getSiteBaseUrl(siteDomain);
|
|
352
|
+
const lines = [];
|
|
353
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
354
|
+
lines.push('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">');
|
|
355
|
+
for (const page of pages) {
|
|
356
|
+
lines.push(' <url>');
|
|
357
|
+
lines.push(` <loc>${escapeHtml(new URL(page.routePath, baseUrl).toString())}</loc>`);
|
|
358
|
+
if (page.sourceMirrorUrl) {
|
|
359
|
+
lines.push(` <xhtml:link rel="alternate" type="text/markdown" href="${escapeHtml(page.sourceMirrorUrl)}" />`);
|
|
360
|
+
}
|
|
361
|
+
if (page.updated) {
|
|
362
|
+
lines.push(` <lastmod>${escapeHtml(page.updated)}</lastmod>`);
|
|
363
|
+
}
|
|
364
|
+
lines.push(' </url>');
|
|
365
|
+
}
|
|
366
|
+
lines.push('</urlset>');
|
|
367
|
+
return `${lines.join('\n')}\n`;
|
|
368
|
+
}
|
|
369
|
+
function llmsTextFromPages(siteName, siteDescription, pages, fullSiteContentUrl, shortcodesUrl) {
|
|
370
|
+
const markdownPages = pages.filter((page) => page.kind === 'markdown');
|
|
371
|
+
const htmlPages = pages.filter((page) => page.kind === 'html');
|
|
372
|
+
const lines = [];
|
|
373
|
+
lines.push(`# ${siteName}`);
|
|
374
|
+
if (siteDescription) {
|
|
375
|
+
lines.push(`> ${siteDescription}`);
|
|
376
|
+
}
|
|
377
|
+
lines.push('');
|
|
378
|
+
lines.push('> markdown = source-managed content mirror');
|
|
379
|
+
lines.push('> html = standalone custom HTML page');
|
|
380
|
+
lines.push('');
|
|
381
|
+
if (fullSiteContentUrl) {
|
|
382
|
+
lines.push('## Full Site Content');
|
|
383
|
+
lines.push(`- markdown | source: [fullSiteContent.md](${fullSiteContentUrl}) | reference for the entire site in one concise file`);
|
|
384
|
+
lines.push('');
|
|
385
|
+
}
|
|
386
|
+
lines.push('## Markdown Pages');
|
|
387
|
+
for (const page of markdownPages) {
|
|
388
|
+
lines.push(`- markdown | source: [source.md](${page.sourceMirrorUrl ?? '#'}) | page: [${page.title}](${page.pageUrl})${page.description ? ` | ${page.description}` : ''}`);
|
|
389
|
+
}
|
|
390
|
+
lines.push('');
|
|
391
|
+
lines.push('## Custom HTML Pages');
|
|
392
|
+
for (const page of htmlPages) {
|
|
393
|
+
lines.push(`- html | source: [source.html](${page.sourceMirrorUrl ?? '#'}) | page: [${page.title}](${page.pageUrl})${page.description ? ` | ${page.description}` : ''}`);
|
|
394
|
+
}
|
|
395
|
+
if (shortcodesUrl) {
|
|
396
|
+
lines.push('');
|
|
397
|
+
lines.push('## Shortcodes Reference');
|
|
398
|
+
lines.push(`- markdown | source: [shortcodes.md](${shortcodesUrl}) | reference for shortcode syntax and LLM-facing page structure`);
|
|
399
|
+
}
|
|
400
|
+
return `${lines.join('\n')}\n`;
|
|
401
|
+
}
|
|
402
|
+
function fullSiteContentMarkdownFromPages(site, pages) {
|
|
403
|
+
const lines = ['# Site Content', ''];
|
|
404
|
+
const markdownPages = pages.filter((page) => page.kind === 'markdown');
|
|
405
|
+
const htmlPages = pages.filter((page) => page.kind === 'html');
|
|
406
|
+
const seenBlocks = new Set();
|
|
407
|
+
for (const page of markdownPages) {
|
|
408
|
+
const blocks = markdownSourceToBlocks(page.body, site);
|
|
409
|
+
if (!blocks.length) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
appendPageReference(lines, page.title, page.routePath);
|
|
413
|
+
appendUniqueBlocks(lines, blocks, seenBlocks);
|
|
414
|
+
lines.push('');
|
|
415
|
+
}
|
|
416
|
+
if (htmlPages.length) {
|
|
417
|
+
for (const page of htmlPages) {
|
|
418
|
+
const text = stripHtmlMarkup(page.body);
|
|
419
|
+
if (!text) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
appendPageReference(lines, page.title, page.routePath);
|
|
423
|
+
appendUniqueBlocks(lines, [text], seenBlocks);
|
|
424
|
+
lines.push('');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return `${lines.join('\n')}\n`;
|
|
428
|
+
}
|
|
429
|
+
function validateInternalLinks(html, currentRoute, siteDomain, knownRoutes) {
|
|
430
|
+
const baseUrl = new URL(currentRoute === '/' ? '/' : currentRoute, getSiteBaseUrl(siteDomain));
|
|
431
|
+
const links = extractLinks(html);
|
|
432
|
+
for (const link of links) {
|
|
433
|
+
if (isIgnoredLink(link) || isExternalUrl(link)) {
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const resolved = new URL(link, baseUrl).pathname;
|
|
437
|
+
if (isStaticAssetPath(resolved) || isSourceMirrorPath(resolved) || isGeneratedDocPath(resolved)) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const normalized = resolved === '/' ? '/' : resolved.endsWith('/') ? resolved : `${resolved}/`;
|
|
441
|
+
if (!knownRoutes.has(normalized)) {
|
|
442
|
+
throw new Error(`Broken internal link detected from ${currentRoute} to ${link} (resolved to ${normalized})`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
export async function buildSite(rootDir = process.cwd()) {
|
|
447
|
+
const distDir = path.join(rootDir, 'dist');
|
|
448
|
+
await Promise.all([removeIfExists(distDir), ensureDir(distDir)]);
|
|
449
|
+
const [site, theme, navigation, sources] = await Promise.all([
|
|
450
|
+
loadSiteConfig(rootDir),
|
|
451
|
+
loadThemeConfig(rootDir),
|
|
452
|
+
loadNavigationConfig(rootDir),
|
|
453
|
+
loadContentSources(rootDir)
|
|
454
|
+
]);
|
|
455
|
+
const published = sources.filter(isPublished);
|
|
456
|
+
if (!published.length) {
|
|
457
|
+
throw new Error('No published content was found. Add at least one markdown or HTML page under content/.');
|
|
458
|
+
}
|
|
459
|
+
const routeMap = new Map();
|
|
460
|
+
for (const source of published) {
|
|
461
|
+
if (routeMap.has(source.routePath)) {
|
|
462
|
+
throw new Error(`Route collision detected for ${source.routePath}: ${routeMap.get(source.routePath)} and ${source.sourcePath}`);
|
|
463
|
+
}
|
|
464
|
+
routeMap.set(source.routePath, source.sourcePath);
|
|
465
|
+
}
|
|
466
|
+
const knownRoutes = new Set(published.map((source) => source.routePath));
|
|
467
|
+
const shortcodesDoc = await readOptionalTextFile(rootDir, '.skills/shortcodes.md');
|
|
468
|
+
const shortcodesUrl = shortcodesDoc ? 'shortcodes.md' : undefined;
|
|
469
|
+
const llmsPages = [];
|
|
470
|
+
const sitemapPages = [];
|
|
471
|
+
for (const source of published) {
|
|
472
|
+
const artifact = await buildPageArtifact({
|
|
473
|
+
source,
|
|
474
|
+
site,
|
|
475
|
+
theme,
|
|
476
|
+
navigation,
|
|
477
|
+
allPublishedSources: published
|
|
478
|
+
});
|
|
479
|
+
const outputPath = routeToOutputPath(distDir, source.routePath, 'index.html');
|
|
480
|
+
const jsonPath = routeToOutputPath(distDir, source.routePath, 'page.json');
|
|
481
|
+
const sourceMirrorPath = routeToOutputPath(distDir, source.routePath, artifact.sourceMirror?.path ?? 'source.md');
|
|
482
|
+
validateInternalLinks(artifact.html, source.routePath, site.site.domain, knownRoutes);
|
|
483
|
+
await writeRenderedPage(outputPath, artifact.html);
|
|
484
|
+
await writeFileEnsured(jsonPath, pageJsonToText(artifact.pageJson));
|
|
485
|
+
await writeFileEnsured(sourceMirrorPath, artifact.sourceMirror?.content ?? '');
|
|
486
|
+
llmsPages.push({
|
|
487
|
+
routePath: artifact.pageJson.routePath,
|
|
488
|
+
title: artifact.pageJson.title,
|
|
489
|
+
description: artifact.pageJson.description,
|
|
490
|
+
kind: artifact.pageJson.kind,
|
|
491
|
+
pageUrl: artifact.pageJson.url,
|
|
492
|
+
sourceMirrorUrl: artifact.sourceMirrorUrl
|
|
493
|
+
});
|
|
494
|
+
sitemapPages.push({
|
|
495
|
+
routePath: artifact.pageJson.routePath,
|
|
496
|
+
sourceMirrorUrl: artifact.sourceMirrorUrl,
|
|
497
|
+
updated: artifact.pageJson.updated
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
await copyPublicAssets(rootDir, distDir);
|
|
501
|
+
if (shortcodesDoc) {
|
|
502
|
+
await writeFileEnsured(path.join(distDir, 'shortcodes.md'), shortcodesDoc);
|
|
503
|
+
}
|
|
504
|
+
const fullSiteContentMarkdown = fullSiteContentMarkdownFromPages(site, published.map((source) => ({
|
|
505
|
+
routePath: source.routePath,
|
|
506
|
+
sourcePath: source.sourcePath,
|
|
507
|
+
kind: source.kind,
|
|
508
|
+
title: buildPageTitle(source, site.site.name),
|
|
509
|
+
body: source.body
|
|
510
|
+
})));
|
|
511
|
+
await writeFileEnsured(path.join(distDir, 'fullSiteContent.md'), fullSiteContentMarkdown);
|
|
512
|
+
await writeFileEnsured(path.join(distDir, 'llms.txt'), llmsTextFromPages(site.site.name, site.site.description, llmsPages, 'fullSiteContent.md', shortcodesUrl));
|
|
513
|
+
await writeFileEnsured(path.join(distDir, 'sitemap.xml'), sitemapXmlFromPages(site.site.domain, sitemapPages));
|
|
514
|
+
console.log(`Built ${published.length} pages into ${distDir}`);
|
|
515
|
+
console.log(`Site: ${site.site.name}`);
|
|
516
|
+
const homePage = published.find((page) => page.routePath === '/') ?? published[0];
|
|
517
|
+
console.log(`Home title: ${buildPageTitle(homePage, site.site.name)}`);
|
|
518
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildSite } from './build.js';
|
|
4
|
+
import { initProject } from './init.js';
|
|
5
|
+
import { startPreviewServer } from './server.js';
|
|
6
|
+
function usage() {
|
|
7
|
+
return `Usage:
|
|
8
|
+
opnPrs init
|
|
9
|
+
opnPrs build
|
|
10
|
+
opnPrs run
|
|
11
|
+
|
|
12
|
+
Aliases:
|
|
13
|
+
opnPress init
|
|
14
|
+
opnPress build
|
|
15
|
+
opnPress run
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
async function runCommand(rootDir) {
|
|
19
|
+
await buildSite(rootDir);
|
|
20
|
+
const distDir = path.join(rootDir, 'dist');
|
|
21
|
+
const server = await startPreviewServer(distDir);
|
|
22
|
+
console.log(`Preview available at ${server.url}`);
|
|
23
|
+
const shutdown = async () => {
|
|
24
|
+
await server.close();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
};
|
|
27
|
+
process.on('SIGINT', () => {
|
|
28
|
+
void shutdown();
|
|
29
|
+
});
|
|
30
|
+
process.on('SIGTERM', () => {
|
|
31
|
+
void shutdown();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async function main() {
|
|
35
|
+
const [command] = process.argv.slice(2);
|
|
36
|
+
const rootDir = process.cwd();
|
|
37
|
+
switch (command) {
|
|
38
|
+
case 'init': {
|
|
39
|
+
const initialized = await initProject(rootDir);
|
|
40
|
+
if (!initialized) {
|
|
41
|
+
console.log('OpnPress is already initialized.');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
console.log(`Initialized OpnPress in ${rootDir}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
case 'build':
|
|
48
|
+
await buildSite(rootDir);
|
|
49
|
+
return;
|
|
50
|
+
case 'run':
|
|
51
|
+
await runCommand(rootDir);
|
|
52
|
+
return;
|
|
53
|
+
case 'help':
|
|
54
|
+
case '--help':
|
|
55
|
+
case '-h':
|
|
56
|
+
case undefined:
|
|
57
|
+
console.log(usage());
|
|
58
|
+
return;
|
|
59
|
+
default:
|
|
60
|
+
console.error(`Unknown command: ${command}`);
|
|
61
|
+
console.log(usage());
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
main().catch((error) => {
|
|
66
|
+
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
});
|