@opnpress/opnpress-cli 0.1.1 → 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/README.md CHANGED
@@ -61,7 +61,10 @@ Release flow:
61
61
  5. Update `package.json` version.
62
62
  6. Create and push a tag such as `v0.1.1`.
63
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.
64
+ 8. npm publishes `@opnpress/opnpress-cli` and GitHub creates a release for the tag.
65
+
66
+ Provenance is only generated automatically when the source repository is public.
67
+ If this GitHub repository stays private, publish without `--provenance`.
65
68
 
66
69
  The package is org-scoped and public, so `npm` treats each `name + version` combination as permanent once published.
67
70
 
package/dist/build.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import sharp from 'sharp';
3
4
  import { unified } from 'unified';
4
5
  import remarkParse from 'remark-parse';
5
6
  import remarkGfm from 'remark-gfm';
@@ -7,7 +8,7 @@ import YAML from 'yaml';
7
8
  import { loadNavigationConfig, loadSiteConfig, loadThemeConfig } from './config.js';
8
9
  import { buildPageTitle, isPublished, loadContentSources } from './content.js';
9
10
  import { buildPageArtifact, writeRenderedPage } from './render.js';
10
- import { escapeHtml, ensureDir, writeFileEnsured } from './utils.js';
11
+ import { escapeHtml, ensureDir, walkFiles, writeFileEnsured } from './utils.js';
11
12
  async function removeIfExists(target) {
12
13
  await fs.rm(target, { recursive: true, force: true });
13
14
  }
@@ -21,6 +22,112 @@ async function copyPublicAssets(rootDir, distDir) {
21
22
  }
22
23
  await fs.cp(publicDir, distDir, { recursive: true, force: true });
23
24
  }
25
+ function isStaticAssetPath(pathname) {
26
+ return /\.(avif|bmp|css|gif|ico|jpe?g|js|png|svg|webp|woff2?|ttf|otf|pdf)$/i.test(pathname);
27
+ }
28
+ function isMachineReadablePath(pathname) {
29
+ return /\.(html?|json|md|xml|txt)$/i.test(pathname);
30
+ }
31
+ function toHeadersPath(relativeFilePath) {
32
+ return `/${relativeFilePath.replace(/\\/g, '/')}`;
33
+ }
34
+ function normalizeAnalyticsId(value) {
35
+ if (!value) {
36
+ return undefined;
37
+ }
38
+ const trimmed = value.trim();
39
+ if (!/^G-[A-Z0-9][A-Z0-9-]*$/i.test(trimmed)) {
40
+ return undefined;
41
+ }
42
+ return trimmed;
43
+ }
44
+ async function prepareHeaderLogoAsset(rootDir, distDir, logoPath) {
45
+ if (!logoPath) {
46
+ return undefined;
47
+ }
48
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(logoPath) || logoPath.startsWith('data:')) {
49
+ return logoPath;
50
+ }
51
+ const normalizedLogoPath = logoPath.replace(/^\/+/, '');
52
+ const sourceLogoPath = path.join(rootDir, 'public', normalizedLogoPath);
53
+ try {
54
+ await fs.access(sourceLogoPath);
55
+ }
56
+ catch {
57
+ return logoPath;
58
+ }
59
+ if (path.extname(normalizedLogoPath).toLowerCase() === '.svg') {
60
+ return `/${normalizedLogoPath}`;
61
+ }
62
+ const resizedLogoPath = normalizedLogoPath.replace(/\.(png|jpe?g|webp|avif|gif)$/i, '.opnpress.webp');
63
+ const outputPath = path.join(distDir, resizedLogoPath);
64
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
65
+ await sharp(sourceLogoPath)
66
+ .resize({ width: 72, height: 72, fit: 'inside', withoutEnlargement: true })
67
+ .webp({ quality: 82 })
68
+ .toFile(outputPath);
69
+ return `/${resizedLogoPath}`;
70
+ }
71
+ function extractAnchorLinks(html) {
72
+ const links = [];
73
+ const pattern = /<a\b[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
74
+ let match;
75
+ while ((match = pattern.exec(html))) {
76
+ const text = stripHtmlMarkup(match[2]).replace(/\s+/g, ' ').trim();
77
+ links.push({ href: match[1], text });
78
+ }
79
+ return links;
80
+ }
81
+ function duplicateLinkWarnings(html, currentRoute) {
82
+ const counts = new Map();
83
+ const labels = new Map();
84
+ for (const link of extractAnchorLinks(html)) {
85
+ if (!link.href || !link.text) {
86
+ continue;
87
+ }
88
+ const key = `${link.href}|||${link.text.toLowerCase()}`;
89
+ counts.set(key, (counts.get(key) ?? 0) + 1);
90
+ labels.set(key, `${link.text} -> ${link.href}`);
91
+ }
92
+ return [...counts.entries()]
93
+ .filter(([, count]) => count > 1)
94
+ .map(([key, count]) => `Duplicate link purpose detected on ${currentRoute}: ${labels.get(key)} appears ${count} times.`);
95
+ }
96
+ function generatedRobotsTxt(siteDomain) {
97
+ const sitemapUrl = new URL('sitemap.xml', getSiteBaseUrl(siteDomain)).toString();
98
+ return `User-agent: *\nAllow: /\nSitemap: ${sitemapUrl}\n`;
99
+ }
100
+ function generatedHeaders(pageOutputs, assetOutputs) {
101
+ const securityHeaders = [
102
+ ` Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; script-src 'self' https://www.googletagmanager.com 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https:; connect-src 'self' https://www.google-analytics.com https://www.googletagmanager.com; form-action 'self' https:; frame-src 'self' https:; require-trusted-types-for 'script'`,
103
+ ' Cross-Origin-Opener-Policy: same-origin',
104
+ ' Cross-Origin-Resource-Policy: same-site',
105
+ ' Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=()',
106
+ ' Referrer-Policy: strict-origin-when-cross-origin',
107
+ ' Strict-Transport-Security: max-age=31536000; includeSubDomains',
108
+ ' X-Content-Type-Options: nosniff',
109
+ ' X-Frame-Options: SAMEORIGIN',
110
+ ' X-Permitted-Cross-Domain-Policies: none'
111
+ ];
112
+ const lines = [];
113
+ for (const outputPath of [...pageOutputs, '/robots.txt']) {
114
+ lines.push(outputPath);
115
+ for (const header of securityHeaders) {
116
+ lines.push(header);
117
+ }
118
+ lines.push(' Cache-Control: public, max-age=0, must-revalidate');
119
+ lines.push('');
120
+ }
121
+ for (const outputPath of assetOutputs) {
122
+ lines.push(outputPath);
123
+ for (const header of securityHeaders) {
124
+ lines.push(header);
125
+ }
126
+ lines.push(' Cache-Control: public, max-age=31536000, immutable');
127
+ lines.push('');
128
+ }
129
+ return `${lines.join('\n').trimEnd()}\n`;
130
+ }
24
131
  function getSiteBaseUrl(domain) {
25
132
  if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(domain)) {
26
133
  return domain.endsWith('/') ? domain : `${domain}/`;
@@ -33,14 +140,16 @@ function isExternalUrl(url) {
33
140
  function isIgnoredLink(url) {
34
141
  return url.startsWith('#') || url.startsWith('mailto:') || url.startsWith('tel:') || url.startsWith('javascript:');
35
142
  }
36
- function isStaticAssetPath(pathname) {
37
- return /\.(png|jpe?g|gif|webp|svg|ico|css|js|json|xml|txt)$/i.test(pathname);
38
- }
39
143
  function isSourceMirrorPath(pathname) {
40
144
  return pathname === '/source.md' || pathname === '/source.html' || pathname.endsWith('/source.md') || pathname.endsWith('/source.html');
41
145
  }
42
146
  function isGeneratedDocPath(pathname) {
43
- return pathname === '/fullSiteContent.md' || pathname.endsWith('/fullSiteContent.md');
147
+ return (pathname === '/fullSiteContent.md' ||
148
+ pathname === '/llms.txt' ||
149
+ pathname === '/shortcodes.md' ||
150
+ pathname.endsWith('/fullSiteContent.md') ||
151
+ pathname.endsWith('/llms.txt') ||
152
+ pathname.endsWith('/shortcodes.md'));
44
153
  }
45
154
  function extractLinks(html) {
46
155
  const links = [];
@@ -466,20 +575,54 @@ export async function buildSite(rootDir = process.cwd()) {
466
575
  const knownRoutes = new Set(published.map((source) => source.routePath));
467
576
  const shortcodesDoc = await readOptionalTextFile(rootDir, '.skills/shortcodes.md');
468
577
  const shortcodesUrl = shortcodesDoc ? 'shortcodes.md' : undefined;
578
+ const configuredAnalyticsId = site.integrations.analytics.id ?? site.integrations.analytics.measurementId;
579
+ const normalizedAnalyticsId = normalizeAnalyticsId(configuredAnalyticsId);
580
+ if (configuredAnalyticsId && !normalizedAnalyticsId) {
581
+ console.warn('Skipping analytics because the configured analytics ID is incomplete or invalid.');
582
+ }
583
+ const renderSite = normalizedAnalyticsId
584
+ ? {
585
+ ...site,
586
+ integrations: {
587
+ ...site.integrations,
588
+ analytics: {
589
+ ...site.integrations.analytics,
590
+ id: normalizedAnalyticsId,
591
+ measurementId: normalizedAnalyticsId
592
+ }
593
+ }
594
+ }
595
+ : {
596
+ ...site,
597
+ integrations: {
598
+ ...site.integrations,
599
+ analytics: {
600
+ ...site.integrations.analytics,
601
+ id: undefined,
602
+ measurementId: undefined
603
+ }
604
+ }
605
+ };
606
+ await copyPublicAssets(rootDir, distDir);
607
+ const headerLogoUrl = await prepareHeaderLogoAsset(rootDir, distDir, site.site.logo);
469
608
  const llmsPages = [];
470
609
  const sitemapPages = [];
471
610
  for (const source of published) {
472
611
  const artifact = await buildPageArtifact({
473
612
  source,
474
- site,
613
+ site: renderSite,
475
614
  theme,
476
615
  navigation,
477
- allPublishedSources: published
616
+ allPublishedSources: published,
617
+ headerLogoUrl
478
618
  });
479
619
  const outputPath = routeToOutputPath(distDir, source.routePath, 'index.html');
480
620
  const jsonPath = routeToOutputPath(distDir, source.routePath, 'page.json');
481
621
  const sourceMirrorPath = routeToOutputPath(distDir, source.routePath, artifact.sourceMirror?.path ?? 'source.md');
482
622
  validateInternalLinks(artifact.html, source.routePath, site.site.domain, knownRoutes);
623
+ for (const warning of duplicateLinkWarnings(artifact.html, source.routePath)) {
624
+ console.warn(warning);
625
+ }
483
626
  await writeRenderedPage(outputPath, artifact.html);
484
627
  await writeFileEnsured(jsonPath, pageJsonToText(artifact.pageJson));
485
628
  await writeFileEnsured(sourceMirrorPath, artifact.sourceMirror?.content ?? '');
@@ -497,7 +640,6 @@ export async function buildSite(rootDir = process.cwd()) {
497
640
  updated: artifact.pageJson.updated
498
641
  });
499
642
  }
500
- await copyPublicAssets(rootDir, distDir);
501
643
  if (shortcodesDoc) {
502
644
  await writeFileEnsured(path.join(distDir, 'shortcodes.md'), shortcodesDoc);
503
645
  }
@@ -511,6 +653,28 @@ export async function buildSite(rootDir = process.cwd()) {
511
653
  await writeFileEnsured(path.join(distDir, 'fullSiteContent.md'), fullSiteContentMarkdown);
512
654
  await writeFileEnsured(path.join(distDir, 'llms.txt'), llmsTextFromPages(site.site.name, site.site.description, llmsPages, 'fullSiteContent.md', shortcodesUrl));
513
655
  await writeFileEnsured(path.join(distDir, 'sitemap.xml'), sitemapXmlFromPages(site.site.domain, sitemapPages));
656
+ const distFiles = await walkFiles(distDir);
657
+ const generatedPageOutputs = new Set();
658
+ const generatedAssetOutputs = new Set();
659
+ for (const filePath of distFiles) {
660
+ const relativePath = toHeadersPath(path.relative(distDir, filePath));
661
+ if (relativePath === '/_headers' || relativePath === '/robots.txt') {
662
+ continue;
663
+ }
664
+ if (isMachineReadablePath(filePath)) {
665
+ generatedPageOutputs.add(relativePath);
666
+ continue;
667
+ }
668
+ if (isStaticAssetPath(filePath)) {
669
+ generatedAssetOutputs.add(relativePath);
670
+ }
671
+ }
672
+ await writeFileEnsured(path.join(distDir, '_headers'), generatedHeaders([...generatedPageOutputs].sort(), [...generatedAssetOutputs].sort()));
673
+ await writeFileEnsured(path.join(distDir, 'robots.txt'), generatedRobotsTxt(site.site.domain));
674
+ const robotsContents = await fs.readFile(path.join(distDir, 'robots.txt'), 'utf8');
675
+ if (!robotsContents.includes('Sitemap: ')) {
676
+ throw new Error('Generated robots.txt is missing the sitemap reference.');
677
+ }
514
678
  console.log(`Built ${published.length} pages into ${distDir}`);
515
679
  console.log(`Site: ${site.site.name}`);
516
680
  const homePage = published.find((page) => page.routePath === '/') ?? published[0];
package/dist/config.js CHANGED
@@ -83,6 +83,7 @@ const BookingCalendarIntegrationSchema = z.object({
83
83
  });
84
84
  const AnalyticsIntegrationSchema = z.object({
85
85
  provider: z.enum(['google-analytics']).default('google-analytics'),
86
+ id: z.string().min(1).optional(),
86
87
  measurementId: z.string().min(1).optional()
87
88
  });
88
89
  const MapsIntegrationSchema = z.object({
package/dist/render.js CHANGED
@@ -658,8 +658,8 @@ function discoveryHeadMarkup(params) {
658
658
  function normalizeShortcodeText(value) {
659
659
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
660
660
  }
661
- function analyticsSnippet(measurementId) {
662
- const escapedId = escapeHtml(measurementId);
661
+ function analyticsSnippet(analyticsId) {
662
+ const escapedId = escapeHtml(analyticsId);
663
663
  return `
664
664
  <script async src="https://www.googletagmanager.com/gtag/js?id=${escapedId}"></script>
665
665
  <script>
@@ -1791,7 +1791,7 @@ function buildThemeCss(theme) {
1791
1791
  `;
1792
1792
  }
1793
1793
  export async function buildPageArtifact(params) {
1794
- const { source, site, theme, navigation, allPublishedSources } = params;
1794
+ const { source, site, theme, navigation, allPublishedSources, headerLogoUrl } = params;
1795
1795
  const title = buildPageTitle(source, site.site.name);
1796
1796
  const description = source.frontmatter.description ?? site.site.description ?? '';
1797
1797
  const canonicalUrl = source.frontmatter.canonical
@@ -1840,13 +1840,14 @@ export async function buildPageArtifact(params) {
1840
1840
  description,
1841
1841
  url: canonicalUrl
1842
1842
  };
1843
- const analytics = site.integrations.analytics.measurementId
1844
- ? analyticsSnippet(site.integrations.analytics.measurementId)
1843
+ const analyticsId = site.integrations.analytics.id ?? site.integrations.analytics.measurementId;
1844
+ const analytics = analyticsId
1845
+ ? analyticsSnippet(analyticsId)
1845
1846
  : '';
1846
1847
  const poweredBy = site.branding.poweredByOpnPress
1847
1848
  ? `<a class="nav-link" href="https://opnpress.com" rel="noopener noreferrer">Built with OpnPress</a>`
1848
1849
  : '';
1849
- const logoUrl = site.site.logo ? hrefForAsset(source.routePath, site.site.logo) : '';
1850
+ const logoUrl = headerLogoUrl ?? (site.site.logo ? hrefForAsset(source.routePath, site.site.logo) : '');
1850
1851
  const faviconAsset = site.site.favicon ?? site.site.logo;
1851
1852
  const faviconUrl = faviconAsset ? hrefForAsset(source.routePath, faviconAsset) : '';
1852
1853
  const twitterSiteHandle = socialMetaHandle(site.socials.twitter ?? site.socials.x);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opnpress/opnpress-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "repository": {
@@ -34,6 +34,7 @@
34
34
  "rehype-raw": "^7.0.0",
35
35
  "rehype-slug": "^6.0.0",
36
36
  "rehype-stringify": "^10.0.1",
37
+ "sharp": "^0.34.4",
37
38
  "remark-gfm": "^4.0.1",
38
39
  "remark-parse": "^11.0.0",
39
40
  "remark-rehype": "^11.1.2",
@@ -24,10 +24,9 @@ jobs:
24
24
  uses: actions/setup-node@v4
25
25
  with:
26
26
  node-version: '22'
27
- cache: npm
28
27
 
29
28
  - name: Build site
30
- run: npx -y @opnpress/opnpress-cli@latest opnPress build
29
+ run: npx --yes @opnpress/opnpress-cli@latest build
31
30
 
32
31
  - name: Publish to Cloudflare Pages
33
32
  uses: cloudflare/wrangler-action@v3
@@ -4,3 +4,4 @@ This folder stores starter guidance for building an OpnPress site.
4
4
 
5
5
  The files here are copied by `opnPress init`, so edit the templates in this repository to change the scaffold.
6
6
  Key starter skills include page editing, navigation, integrations, and audits.
7
+ Build-enforced concerns such as `robots.txt`, headers, and logo optimization should be treated as generator behavior, not as manual content edits.
@@ -5,6 +5,7 @@ Use this skill when adding or changing integrations.
5
5
  ## What To Do
6
6
 
7
7
  - define the provider in `site.config.yaml`
8
+ - use the public analytics ID field when tracking is enabled
8
9
  - author the semantic block in markdown
9
10
  - keep the content provider-agnostic
10
11
  - prefer integration-backed blocks for shared site data
@@ -64,6 +65,7 @@ Each value can be a handle or a full URL. The renderer converts handles into pub
64
65
  - `site.socials.*` for social identity links
65
66
  - `site.branding.poweredByOpnPress` for footer attribution
66
67
  - `site.deployment.provider` and `site.deployment.cloudflare.*` for publishing
68
+ - `site.integrations.analytics.id` for production-only analytics tracking
67
69
  - `site.integrations.contactForm.*`
68
70
  - `site.integrations.shareableLinks.*`
69
71
  - `site.integrations.companyInfo.*`
@@ -81,4 +83,4 @@ Each value can be a handle or a full URL. The renderer converts handles into pub
81
83
  - verify required config values are present
82
84
  - verify the block renders instead of falling back to escaped code
83
85
  - verify the resulting links stay relative when they should
84
-
86
+ - verify analytics is omitted when the ID is missing or incomplete
@@ -4,17 +4,18 @@ Use this skill when checking a site for missing pieces and recommending fixes.
4
4
 
5
5
  ## What To Check
6
6
 
7
- - required frontmatter fields
7
+ - duplicate links with the same purpose
8
8
  - page titles and descriptions
9
9
  - canonical and social metadata
10
- - navigation completeness
11
- - footer branding
10
+ - heading structure and semantic ordering
11
+ - sitemap coverage and reachable content
12
+ - footer branding and attribution
12
13
  - integration config presence
13
- - build output completeness
14
+ - SEO gaps that affect crawl or clarity
14
15
 
15
16
  ## Output Style
16
17
 
17
18
  - list what is missing
18
19
  - explain the impact
19
20
  - recommend the next fix in file terms
20
-
21
+ - separate content issues from build-enforced issues
@@ -9,9 +9,10 @@ Use this skill when changing the site theme.
9
9
  - fonts
10
10
  - sizing
11
11
  - limited layout controls
12
+ - logo asset choices and displayed size
12
13
 
13
14
  ## Recommended Checks
14
15
 
15
16
  - verify the theme still reads well on mobile
16
17
  - verify contrast and spacing remain usable
17
-
18
+ - verify the header logo fits the intended slot cleanly
@@ -7,9 +7,10 @@ Use this skill when changing global header or footer content.
7
7
  - edit the site shell, not individual pages, for global header/footer changes
8
8
  - keep branding links intentional
9
9
  - preserve readable navigation on mobile
10
+ - keep repeated destinations out of visible labels unless duplication is deliberate
10
11
 
11
12
  ## Recommended Checks
12
13
 
13
14
  - verify the header still links home correctly
14
15
  - verify the footer still includes required brand or attribution links
15
-
16
+ - verify the same destination is not repeated with confusing labels
@@ -8,9 +8,10 @@ Use this skill when editing site navigation.
8
8
  - keep labels short and clear
9
9
  - use relative internal paths for site pages
10
10
  - keep external links explicit
11
+ - avoid repeated destinations unless the label and purpose are intentionally different
11
12
 
12
13
  ## Recommended Checks
13
14
 
14
15
  - verify every internal navigation target exists
15
16
  - verify footer and header nav stay consistent with the site structure
16
-
17
+ - verify duplicate destinations are intentional
@@ -30,13 +30,6 @@ site:
30
30
  # branding:
31
31
  # poweredByOpnPress: true
32
32
 
33
- # Deployment target for the GitHub Actions workflow.
34
- # deployment:
35
- # provider: cloudflare-pages
36
- # cloudflare:
37
- # projectName: opnpress
38
- # productionBranch: master
39
-
40
33
  # Integrations and reusable contact blocks are rendered from markdown shortcodes.
41
34
  # integrations:
42
35
  # contactForm:
@@ -74,7 +67,7 @@ site:
74
67
  # buttonLabel: Book a call
75
68
  # analytics:
76
69
  # provider: google-analytics
77
- # measurementId: G-EXAMPLE
70
+ # id: G-EXAMPLE
78
71
  # maps:
79
72
  # provider: google-maps
80
73
  # embedUrl: https://www.google.com/maps?q=Chicago&output=embed