mnfst-render 0.5.45 → 0.5.47

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.
Files changed (2) hide show
  1. package/manifest.render.mjs +101 -20
  2. package/package.json +1 -1
@@ -840,20 +840,43 @@ function discoverDataPaths(manifest, rootDir, wildcardBases = [], locales = [])
840
840
  return wildcardBases.some((base) => rest.startsWith(base + '/'));
841
841
  }
842
842
 
843
+ // A source mounts under the wildcard route named after it: `docs/*` for key
844
+ // `docs`, or a nested base ending in the key (`docs/framework/*` for key
845
+ // `framework`).
846
+ const mounts = new Map();
847
+ function mountFor(sourceKey) {
848
+ if (!sourceKey) return null;
849
+ if (mounts.has(sourceKey)) return mounts.get(sourceKey);
850
+ let mount = null;
851
+ if (wildcardBases.includes(sourceKey)) mount = sourceKey;
852
+ else {
853
+ const nested = wildcardBases.filter((b) => b.split('/').pop() === sourceKey);
854
+ if (nested.length === 1) {
855
+ mount = nested[0];
856
+ process.stderr.write(`prerender: data source "${sourceKey}" mounts under ${mount}/*\n`);
857
+ } else if (nested.length > 1) {
858
+ process.stderr.write(`prerender: data source "${sourceKey}" matches ${nested.map((b) => b + '/*').join(', ')}; not mounted (ambiguous)\n`);
859
+ }
860
+ }
861
+ mounts.set(sourceKey, mount);
862
+ return mount;
863
+ }
864
+
843
865
  function expandCandidates(rawPath, sourceKey) {
844
866
  const p = String(rawPath || '').replace(/^\/+|\/+$/g, '');
845
867
  if (!p) return [];
846
868
  const candidates = [p];
847
869
  if (wildcardBases.length === 0) return candidates;
848
- if (!sourceKey || !wildcardBases.includes(sourceKey)) return candidates;
870
+ const mount = mountFor(sourceKey);
871
+ if (!mount) return candidates;
849
872
  const parts = p.split('/');
850
873
  const hasLocalePrefix = parts.length > 1 && localeSet.has(parts[0].toLowerCase());
851
874
  if (hasLocalePrefix) {
852
875
  const locale = parts[0];
853
876
  const rest = parts.slice(1).join('/');
854
- if (rest && !rest.startsWith(sourceKey + '/')) candidates.push(`${locale}/${sourceKey}/${rest}`);
855
- } else if (!p.startsWith(sourceKey + '/')) {
856
- candidates.push(`${sourceKey}/${p}`);
877
+ if (rest && !rest.startsWith(mount + '/')) candidates.push(`${locale}/${mount}/${rest}`);
878
+ } else if (!p.startsWith(mount + '/')) {
879
+ candidates.push(`${mount}/${p}`);
857
880
  }
858
881
  return candidates;
859
882
  }
@@ -3091,6 +3114,75 @@ ${urlEntries.join('\n')}
3091
3114
  writeLlmsFiles(outputDir, pathList, base, ctx);
3092
3115
  }
3093
3116
 
3117
+ /**
3118
+ * Group llms entries into sections by first URL segment ("Blog" for /blog/*),
3119
+ * root page as "Overview", in first-encounter order. Wherever a path holds two
3120
+ * or more sub-areas that each have their own page and children, each sub-area
3121
+ * becomes its own section labelled by its page title, recursively: /docs/ai/*
3122
+ * and /docs/framework/* split into "AI Plugins" and "Framework", and groups
3123
+ * inside them into "Framework / Core Plugins" and so on. A lone sub-area
3124
+ * (/fr/docs under /fr) is walked through when a split lies below it, and the
3125
+ * first segment then leads the label ("Fr / AI Plugins").
3126
+ */
3127
+ function groupLlmsSections(entries, siteName = '') {
3128
+ const titleCase = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
3129
+ const paths = new Set(entries.map((e) => e.pathSeg));
3130
+ const parents = new Set();
3131
+ for (const p of paths) {
3132
+ const segs = p.split('/');
3133
+ for (let i = 1; i < segs.length; i++) parents.add(segs.slice(0, i).join('/'));
3134
+ }
3135
+ const childAreas = new Map();
3136
+ for (const p of paths) {
3137
+ if (!parents.has(p) || !p.includes('/')) continue;
3138
+ const parent = p.slice(0, p.lastIndexOf('/'));
3139
+ if (!childAreas.has(parent)) childAreas.set(parent, new Set());
3140
+ childAreas.get(parent).add(p);
3141
+ }
3142
+ const splits = (prefix) => (childAreas.get(prefix)?.size || 0) >= 2;
3143
+ const below = new Map();
3144
+ const splitsBelow = (prefix) => {
3145
+ if (!below.has(prefix)) {
3146
+ const kids = childAreas.get(prefix);
3147
+ below.set(prefix, splits(prefix) || (kids?.size === 1 && splitsBelow(kids.values().next().value)));
3148
+ }
3149
+ return below.get(prefix);
3150
+ };
3151
+ const suffix = siteName ? new RegExp(`\\s+[—–|-]\\s+${siteName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) : null;
3152
+ const titles = new Map(entries.map((e) => [e.pathSeg, e.title || '']));
3153
+ const labelCache = new Map();
3154
+ const titleOf = (area) => {
3155
+ if (labelCache.has(area)) return labelCache.get(area);
3156
+ let t = titles.get(area) || '';
3157
+ if (suffix) t = t.replace(suffix, '');
3158
+ t = t.trim();
3159
+ if (!t || t === siteName || t === area) t = titleCase(area.split('/').pop());
3160
+ labelCache.set(area, t);
3161
+ return t;
3162
+ };
3163
+ const sections = new Map();
3164
+ for (const entry of entries) {
3165
+ const segs = entry.pathSeg.split('/');
3166
+ const first = segs[0] || '';
3167
+ let key = first || 'Overview';
3168
+ const parts = first && !splits(first) && splitsBelow(first) ? [titleCase(first)] : [];
3169
+ let prefix = first;
3170
+ for (let i = 1; first && i < segs.length && splitsBelow(prefix); i++) {
3171
+ const child = segs.slice(0, i + 1).join('/');
3172
+ if (!childAreas.get(prefix).has(child)) break;
3173
+ if (splits(prefix)) {
3174
+ key = child;
3175
+ parts.push(titleOf(child));
3176
+ }
3177
+ prefix = child;
3178
+ }
3179
+ const label = parts.length > 1 || (parts.length && splits(first)) ? parts.join(' / ') : (first ? titleCase(first) : 'Overview');
3180
+ if (!sections.has(key)) sections.set(key, { label, entries: [] });
3181
+ sections.get(key).entries.push(entry);
3182
+ }
3183
+ return sections;
3184
+ }
3185
+
3094
3186
  /**
3095
3187
  * Write llms.txt (curated index) and llms-full.txt (concatenated full content)
3096
3188
  * per the llmstxt.org convention. Read each prerendered HTML file in pathList
@@ -3098,9 +3190,7 @@ ${urlEntries.join('\n')}
3098
3190
  * injectMetaInDom + smart defaults, so the output reflects the same layered
3099
3191
  * precedence (data-head → prerender.meta → smart defaults) without re-deriving.
3100
3192
  *
3101
- * Pages are grouped into sections by their first URL segment ("Getting Started"
3102
- * for /docs/getting-started/*, etc.) so the index is browseable. The root /
3103
- * page is treated as the site overview.
3193
+ * Sections come from groupLlmsSections.
3104
3194
  */
3105
3195
  function writeLlmsFiles(outputDir, pathList, liveBase, ctx = {}) {
3106
3196
  const siteName = ctx.siteName || 'Site';
@@ -3121,18 +3211,7 @@ function writeLlmsFiles(outputDir, pathList, liveBase, ctx = {}) {
3121
3211
  });
3122
3212
  }
3123
3213
 
3124
- // Group entries by section. For /a/b/c, the section is "a"; for the root,
3125
- // "Overview". Sections are presented in first-encounter order to preserve
3126
- // whatever order the project's manifest.json or yaml index dictated.
3127
- const sections = new Map();
3128
- const titleCase = (s) => s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
3129
- for (const entry of entries) {
3130
- const first = entry.pathSeg.split('/')[0] || '';
3131
- const sectionKey = first || 'Overview';
3132
- const sectionLabel = first ? titleCase(first) : 'Overview';
3133
- if (!sections.has(sectionKey)) sections.set(sectionKey, { label: sectionLabel, entries: [] });
3134
- sections.get(sectionKey).entries.push(entry);
3135
- }
3214
+ const sections = groupLlmsSections(entries, siteName);
3136
3215
 
3137
3216
  // --- llms.txt: short curated index ---
3138
3217
  let llms = `# ${siteName}\n`;
@@ -4485,7 +4564,7 @@ async function runPrerender(config) {
4485
4564
  // observer state at runtime and therefore need the live Alpine scope.
4486
4565
  const INTERACTIVE_DIRECTIVES = new Set([
4487
4566
  'x-color', 'x-dropdown', 'x-tooltip', 'x-tab', 'x-tabpanel',
4488
- 'x-toast', 'x-carousel', 'x-resize', 'x-anchors', 'x-model',
4567
+ 'x-toast', 'x-carousel', 'x-anchors', 'x-model',
4489
4568
  'x-files', 'x-data-files',
4490
4569
  ]);
4491
4570
  // Runtime-only Alpine magics whose values change after the prerender
@@ -5462,6 +5541,8 @@ if (_isDirectEntry) {
5462
5541
  // Exports for the CLI bin script and for unit testing.
5463
5542
  export {
5464
5543
  main,
5544
+ discoverRoutes,
5545
+ groupLlmsSections,
5465
5546
  acquireOutputLock,
5466
5547
  outputLockPath,
5467
5548
  markPrerenderedManifestComponents,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mnfst-render",
3
- "version": "0.5.45",
3
+ "version": "0.5.47",
4
4
  "description": "Render Manifest sites to static HTML for SEO",
5
5
  "type": "module",
6
6
  "bin": {