@justanarthur/payload-www 0.1.2 → 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/dist/server.d.ts CHANGED
@@ -156,6 +156,19 @@ type CreateCollectionPageExportsArgs<S extends string = "pages"> = {
156
156
  * defined your own Server Component for the collection.
157
157
  */
158
158
  renderPath?: string;
159
+ /**
160
+ * Enable nested (hierarchical) slugs. When `true`, the catch-all
161
+ * `[...slug]` segments are joined with the `_` divider to form the
162
+ * stored slug (URL `/about/us` ⇄ stored `about_us`), URL paths and
163
+ * hreflang alternates are expanded back to `/about/us`, and
164
+ * `generateStaticParams` emits multi-segment params. When `false`
165
+ * (the default) only the first URL segment is used — flat slugs.
166
+ *
167
+ * Pair with `createPagesCollection({ nested: true })` (or
168
+ * `createWWWConfig({ nested: true })`) so the collection's slug
169
+ * validation accepts the `_` divider.
170
+ */
171
+ nested?: boolean;
159
172
  };
160
173
  type CreateCollectionPageExportsDeps<S extends string> = {
161
174
  /**
@@ -234,7 +247,7 @@ type CreateCollectionPageExportsDeps<S extends string> = {
234
247
  * { generateMetadata, generateStaticParams }
235
248
  * Page
236
249
  */
237
- declare function createCollectionPageExports<S extends string = "pages">({ slug, config: configPromise, routing, importMap: importMapArg, renderPath }: CreateCollectionPageExportsArgs<S>, deps: CreateCollectionPageExportsDeps<S>, options?: MetadataOptions): {
250
+ declare function createCollectionPageExports<S extends string = "pages">({ slug, config: configPromise, routing, importMap: importMapArg, renderPath, nested }: CreateCollectionPageExportsArgs<S>, deps: CreateCollectionPageExportsDeps<S>, options?: MetadataOptions): {
238
251
  default: (props: {
239
252
  params: Promise<{
240
253
  locale?: string;
@@ -382,6 +395,14 @@ type CreatePagesCollectionOptions = {
382
395
  * when omitted.
383
396
  */
384
397
  defaultLocale?: CreateRevalidateCollectionHookOptions["defaultLocale"];
398
+ /**
399
+ * Enable nested (hierarchical) slugs. When `true`, the slug field
400
+ * additionally accepts the `_` nesting divider (so `about_us`
401
+ * renders at `/about/us`) and the admin description documents the
402
+ * convention. Pair with `createCollectionPageExports({ nested: true })`
403
+ * on the matching route. Default: `false` (flat, hyphen-only slugs).
404
+ */
405
+ nested?: boolean;
385
406
  };
386
407
  declare const createPagesCollection: (blocks: Block[], options?: Omit<CreatePagesCollectionOptions, "blocks">) => CollectionConfig;
387
408
  import { CollectionConfig as CollectionConfig2 } from "payload";
@@ -439,7 +460,7 @@ type CreateStaticPagesCollectionOptions = {
439
460
  blocks: Block2[];
440
461
  };
441
462
  declare const createStaticPagesCollection: (blocks: Block2[]) => CollectionConfig3;
442
- import { GlobalConfig } from "payload";
463
+ import { Field, GlobalConfig } from "payload";
443
464
  type CreateHeaderGlobalOptions = {
444
465
  /**
445
466
  * Optional override for the `custom.path` Payload uses to resolve
@@ -453,6 +474,17 @@ type CreateHeaderGlobalOptions = {
453
474
  * different list when your host has additional linkable collections.
454
475
  */
455
476
  linkRelationTo?: string[];
477
+ /**
478
+ * Extra fields appended to each `navColumn` link — e.g. a
479
+ * `description` text field or a `navHover` group for a mega-menu.
480
+ * Lets hosts enrich the column-link schema without redefining the
481
+ * whole nav.
482
+ */
483
+ navColumnLinkFields?: Field[];
484
+ /**
485
+ * Extra fields appended to each top-level `navItem` link.
486
+ */
487
+ navItemLinkFields?: Field[];
456
488
  };
457
489
  /**
458
490
  * Build the Header global. The lib's default shape: a `nav` blocks
@@ -460,7 +492,7 @@ type CreateHeaderGlobalOptions = {
460
492
  * and `navItem` (a single link).
461
493
  */
462
494
  declare const createHeaderGlobal: (options?: CreateHeaderGlobalOptions) => GlobalConfig;
463
- import { GlobalConfig as GlobalConfig2 } from "payload";
495
+ import { Field as Field2, GlobalConfig as GlobalConfig2 } from "payload";
464
496
  type CreateFooterGlobalOptions = {
465
497
  /**
466
498
  * Optional override for the `custom.path` Payload uses to resolve
@@ -474,6 +506,16 @@ type CreateFooterGlobalOptions = {
474
506
  * different list when your host has additional linkable collections.
475
507
  */
476
508
  linkRelationTo?: string[];
509
+ /**
510
+ * Extra fields appended to each `navColumn` link — e.g. a
511
+ * `description` text field. Lets hosts enrich the column-link
512
+ * schema without redefining the whole nav.
513
+ */
514
+ navColumnLinkFields?: Field2[];
515
+ /**
516
+ * Extra fields appended to each top-level `navItem` link.
517
+ */
518
+ navItemLinkFields?: Field2[];
477
519
  };
478
520
  /**
479
521
  * Build the Footer global. The lib's default shape mirrors the
@@ -517,6 +559,14 @@ type WWWConfigOptions = {
517
559
  */
518
560
  registerPosts?: boolean;
519
561
  /**
562
+ * Enable nested (hierarchical) Pages slugs. When `true`, the lib's
563
+ * Pages collection accepts the `_` nesting divider in its slug
564
+ * (`about_us` → `/about/us`). Pass the same `nested: true` to
565
+ * `createCollectionPageExports` on the Pages route so URL building,
566
+ * hreflang, and static params expand the divider. Default: `false`.
567
+ */
568
+ nested?: boolean;
569
+ /**
520
570
  * Final say on the lib's default plugin list. Receives the
521
571
  * `[seoPlugin, imageHashPlugin, translator]` array; return the
522
572
  * list to apply. Common uses: drop the translator (no AI
@@ -575,7 +625,7 @@ import { GlobalAfterChangeHook } from "payload";
575
625
  * uses.
576
626
  */
577
627
  declare function createRevalidateGlobalHook(slug: string): GlobalAfterChangeHook;
578
- import { Field, GroupField } from "payload";
628
+ import { Field as Field3, GroupField } from "payload";
579
629
  type LinkAppearances = "default" | "outline";
580
630
  declare const appearanceOptions: Record<LinkAppearances, {
581
631
  label: string;
@@ -597,10 +647,18 @@ type LinkOptions = {
597
647
  overrides?: Partial<GroupField>;
598
648
  /** Legacy alias for the host's custom field overrides. */
599
649
  linkOverrides?: Partial<GroupField>;
650
+ /**
651
+ * Extra fields appended to the link group — e.g. a `description`
652
+ * text field or a `navHover` group for a mega-menu. Lets hosts
653
+ * extend the nav link schema without re-implementing the whole
654
+ * `link` field. Appended after the standard type/label/appearance
655
+ * fields.
656
+ */
657
+ extraFields?: Field3[];
600
658
  };
601
- declare const link: (options?: LinkOptions) => Field;
602
- import { Field as Field2 } from "payload";
603
- declare const linkGroup: (options?: LinkOptions) => Field2;
659
+ declare const link: (options?: LinkOptions) => Field3;
660
+ import { Field as Field4 } from "payload";
661
+ declare const linkGroup: (options?: LinkOptions) => Field4;
604
662
  import { ImportMap as ImportMap2 } from "payload";
605
663
  declare function getFromImportMap(key: string, importMap: ImportMap2): any;
606
664
  declare function generateImportName(type: "block" | "page", slug: string): string;
package/dist/server.js CHANGED
@@ -364,20 +364,25 @@ var createPagesCollection = (blocks, options = {}) => {
364
364
  renderPath = PAGES_RENDER_PATH,
365
365
  slug: collectionSlug = PAGES_SLUG2,
366
366
  localePrefix,
367
- defaultLocale
367
+ defaultLocale,
368
+ nested = false
368
369
  } = options;
370
+ const slugPattern = nested ? /^[a-z0-9_-]+$/ : /^[a-z0-9-]+$/;
369
371
  const slugField = {
370
372
  name: "slug",
371
373
  type: "text",
372
374
  required: true,
373
375
  unique: true,
374
376
  index: true,
375
- admin: { position: "sidebar" },
377
+ admin: {
378
+ position: "sidebar",
379
+ ...nested ? { description: "Lowercase, hyphens for words. Use the `_` divider for nesting, e.g. `about_us` → `/about/us`." } : {}
380
+ },
376
381
  validate: (value) => {
377
382
  if (typeof value !== "string")
378
383
  return "Slug must be a string";
379
- if (value !== "" && !/^[a-z0-9-]+$/.test(value)) {
380
- return "Slug must be lowercase, with hyphens (no spaces or special characters)";
384
+ if (value !== "" && !slugPattern.test(value)) {
385
+ return nested ? "Slug must be lowercase, with hyphens or the `_` nesting divider (no spaces or other special characters)" : "Slug must be lowercase, with hyphens (no spaces or special characters)";
381
386
  }
382
387
  return true;
383
388
  }
@@ -589,7 +594,8 @@ var link = (options = {}) => {
589
594
  disableLabel = false,
590
595
  relationTo = [PAGES_SLUG],
591
596
  localized = false,
592
- overrides = {}
597
+ overrides = {},
598
+ extraFields = []
593
599
  } = options;
594
600
  const result = {
595
601
  name: "link",
@@ -666,6 +672,9 @@ var link = (options = {}) => {
666
672
  options: opts
667
673
  });
668
674
  }
675
+ if (extraFields.length) {
676
+ result.fields.push(...extraFields);
677
+ }
669
678
  return { ...result, ...overrides };
670
679
  };
671
680
 
@@ -685,17 +694,22 @@ function createRevalidateGlobalHook(slug) {
685
694
 
686
695
  // src/data/collections/globals/Header/config.ts
687
696
  var createHeaderGlobal = (options = {}) => {
688
- const { renderPath = HEADER_RENDER_PATH, linkRelationTo = [PAGES_SLUG] } = options;
697
+ const {
698
+ renderPath = HEADER_RENDER_PATH,
699
+ linkRelationTo = [PAGES_SLUG],
700
+ navColumnLinkFields = [],
701
+ navItemLinkFields = []
702
+ } = options;
689
703
  const navColumnBlock = {
690
704
  slug: "navColumn",
691
705
  fields: [
692
706
  { name: "title", type: "text", required: true, localized: true },
693
- { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })] }
707
+ { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navColumnLinkFields })] }
694
708
  ]
695
709
  };
696
710
  const navItemBlock = {
697
711
  slug: "navItem",
698
- fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })]
712
+ fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navItemLinkFields })]
699
713
  };
700
714
  return {
701
715
  slug: "header",
@@ -717,17 +731,22 @@ var createHeaderGlobal = (options = {}) => {
717
731
 
718
732
  // src/data/collections/globals/Footer/config.ts
719
733
  var createFooterGlobal = (options = {}) => {
720
- const { renderPath = FOOTER_RENDER_PATH, linkRelationTo = [PAGES_SLUG] } = options;
734
+ const {
735
+ renderPath = FOOTER_RENDER_PATH,
736
+ linkRelationTo = [PAGES_SLUG],
737
+ navColumnLinkFields = [],
738
+ navItemLinkFields = []
739
+ } = options;
721
740
  const navColumnBlock = {
722
741
  slug: "navColumn",
723
742
  fields: [
724
743
  { name: "title", type: "text", required: true, localized: true },
725
- { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })] }
744
+ { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navColumnLinkFields })] }
726
745
  ]
727
746
  };
728
747
  const navItemBlock = {
729
748
  slug: "navItem",
730
- fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })]
749
+ fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navItemLinkFields })]
731
750
  };
732
751
  return {
733
752
  slug: "footer",
@@ -925,6 +944,7 @@ function createSitemapFile(options) {
925
944
  const collectionDefaults = options.perCollection?.[collectionSlug] ?? {};
926
945
  const urlPrefix = (options.urlPrefixes?.[collectionSlug] ?? "").replace(/\/$/, "");
927
946
  const siteUrl = options.getServerSideURL().replace(/\/$/, "");
947
+ const isNested = options.nested?.[collectionSlug] ?? false;
928
948
  for (const locale of activeLocales) {
929
949
  const docs = await queryAllDocs({
930
950
  collectionSlug,
@@ -940,13 +960,14 @@ function createSitemapFile(options) {
940
960
  if (seen.has(dedupeKey))
941
961
  continue;
942
962
  seen.add(dedupeKey);
943
- const url = `${siteUrl}${prefixFor(locale, defaultLocale, options.localePrefix ?? "always")}${urlPrefix}/${storedSlug}`;
963
+ const slugPath = isNested ? storedSlug.replaceAll("_", "/") : storedSlug;
964
+ const url = `${siteUrl}${prefixFor(locale, defaultLocale, options.localePrefix ?? "always")}${urlPrefix}/${slugPath}`;
944
965
  const languages = await buildHreflangAlternates({
945
966
  siteUrl,
946
967
  locale,
947
968
  urlPrefix,
948
969
  storedSlug,
949
- nested: false,
970
+ nested: isNested,
950
971
  homeSlug: "",
951
972
  defaultLocale,
952
973
  locales: activeLocales,
@@ -977,7 +998,7 @@ function createSitemapFile(options) {
977
998
  }
978
999
  // src/config/createWWWConfig.ts
979
1000
  function createWWWConfig(options) {
980
- const { locales, routing, blocks, linkRelationTo, registerPosts = true, defaultPlugins } = options;
1001
+ const { locales, routing, blocks, linkRelationTo, registerPosts = true, nested = false, defaultPlugins } = options;
981
1002
  const defaultLocale = routing?.defaultLocale ?? locales[0] ?? "";
982
1003
  const localePrefixMode = (() => {
983
1004
  const raw = routing?.localePrefix;
@@ -990,7 +1011,8 @@ function createWWWConfig(options) {
990
1011
  }
991
1012
  const buildPagesCollection = () => createPagesCollection(blocks, {
992
1013
  localePrefix: localePrefixMode,
993
- defaultLocale
1014
+ defaultLocale,
1015
+ nested
994
1016
  });
995
1017
  const buildPostsCollection = () => createPostsCollection({
996
1018
  localePrefix: localePrefixMode,
@@ -1635,7 +1657,8 @@ function createCollectionPageExports({
1635
1657
  config: configPromise,
1636
1658
  routing,
1637
1659
  importMap: importMapArg,
1638
- renderPath
1660
+ renderPath,
1661
+ nested = false
1639
1662
  }, deps, options = {}) {
1640
1663
  const importMap = importMapArg ?? {};
1641
1664
  const { jsonLd: jsonLdOption = true, changefreq = "weekly", priority = 0.5, websiteName } = options;
@@ -1654,7 +1677,7 @@ function createCollectionPageExports({
1654
1677
  const defaultRenderPath = slug === POSTS_SLUG ? POSTS_RENDER_PATH : PAGES_RENDER_PATH;
1655
1678
  const localePrefixMode = typeof routing.localePrefix === "string" ? routing.localePrefix : routing.localePrefix?.mode ?? "always";
1656
1679
  const buildLocalePath = (locale, storedSlug) => {
1657
- const urlPath = getUrlPath(storedSlug, false, HOME_SLUG);
1680
+ const urlPath = getUrlPath(storedSlugToSegments(storedSlug, nested), nested, HOME_SLUG);
1658
1681
  if (localePrefixMode === "never")
1659
1682
  return urlPath;
1660
1683
  if (localePrefixMode === "as-needed" && locale === defaultLocale)
@@ -1679,7 +1702,7 @@ function createCollectionPageExports({
1679
1702
  locale,
1680
1703
  urlPrefix: "",
1681
1704
  storedSlug,
1682
- nested: false,
1705
+ nested,
1683
1706
  homeSlug: HOME_SLUG,
1684
1707
  defaultLocale,
1685
1708
  locales,
@@ -1704,7 +1727,7 @@ function createCollectionPageExports({
1704
1727
  setRequestLocale(locale);
1705
1728
  const { draftMode } = await import("next/headers");
1706
1729
  const { isEnabled: draft } = await draftMode();
1707
- const storedSlug = segmentsToStoredSlug(slugSegments, false);
1730
+ const storedSlug = segmentsToStoredSlug(slugSegments, nested);
1708
1731
  const doc = await fetchDoc(locale, storedSlug, draft);
1709
1732
  if (!doc) {
1710
1733
  const { notFound } = await import("next/navigation");
@@ -1727,7 +1750,6 @@ function createCollectionPageExports({
1727
1750
  });
1728
1751
  const jsonLdNodes = doc ? await generateJsonLd(slugSegments, doc, locale) : [];
1729
1752
  const { alternates: hreflangAlternates, canonical } = await resolveHreflangAlternates(locale, storedSlug);
1730
- console.log("render", { render, doc });
1731
1753
  const inner = /* @__PURE__ */ jsxs3(Fragment4, {
1732
1754
  children: [
1733
1755
  jsonLdNodes.map(({ id, schema }) => /* @__PURE__ */ jsx7("script", {
@@ -1777,7 +1799,7 @@ function createCollectionPageExports({
1777
1799
  const locale = typeof incomingLocale === "string" ? incomingLocale : defaultLocale;
1778
1800
  if (!locales.includes(locale))
1779
1801
  return { title: "Not found", robots: { index: false, follow: false } };
1780
- const storedSlug = segmentsToStoredSlug(slugSegments, false);
1802
+ const storedSlug = segmentsToStoredSlug(slugSegments, nested);
1781
1803
  const doc = await fetchDoc(locale, storedSlug);
1782
1804
  if (!doc)
1783
1805
  return { title: "Not found", robots: { index: false, follow: false } };
@@ -1801,7 +1823,7 @@ function createCollectionPageExports({
1801
1823
  if (jsonLdOption === false || !doc)
1802
1824
  return [];
1803
1825
  const siteUrl = getServerSideURL();
1804
- const storedSlug = segmentsToStoredSlug(slugSegments, false);
1826
+ const storedSlug = segmentsToStoredSlug(slugSegments, nested);
1805
1827
  const urlPath = buildLocalePath(locale, storedSlug);
1806
1828
  const canonical = `${siteUrl}${urlPath}`;
1807
1829
  const entries = Array.isArray(jsonLdOption) ? jsonLdOption : metadataType === "article" ? [{ type: "article" }] : [{ type: "website" }];
@@ -1872,7 +1894,7 @@ function createCollectionPageExports({
1872
1894
  const slugVal = doc.slug;
1873
1895
  if (typeof slugVal !== "string" || slugVal === "")
1874
1896
  continue;
1875
- const segments = storedSlugToSegments(slugVal, false);
1897
+ const segments = storedSlugToSegments(slugVal, nested);
1876
1898
  params.push({ slug: Array.isArray(segments) ? segments : [segments], locale });
1877
1899
  }
1878
1900
  }
package/dist/test.js CHANGED
@@ -201,20 +201,25 @@ var createPagesCollection = (blocks, options = {}) => {
201
201
  renderPath = PAGES_RENDER_PATH,
202
202
  slug: collectionSlug = PAGES_SLUG2,
203
203
  localePrefix,
204
- defaultLocale
204
+ defaultLocale,
205
+ nested = false
205
206
  } = options;
207
+ const slugPattern = nested ? /^[a-z0-9_-]+$/ : /^[a-z0-9-]+$/;
206
208
  const slugField = {
207
209
  name: "slug",
208
210
  type: "text",
209
211
  required: true,
210
212
  unique: true,
211
213
  index: true,
212
- admin: { position: "sidebar" },
214
+ admin: {
215
+ position: "sidebar",
216
+ ...nested ? { description: "Lowercase, hyphens for words. Use the `_` divider for nesting, e.g. `about_us` → `/about/us`." } : {}
217
+ },
213
218
  validate: (value) => {
214
219
  if (typeof value !== "string")
215
220
  return "Slug must be a string";
216
- if (value !== "" && !/^[a-z0-9-]+$/.test(value)) {
217
- return "Slug must be lowercase, with hyphens (no spaces or special characters)";
221
+ if (value !== "" && !slugPattern.test(value)) {
222
+ return nested ? "Slug must be lowercase, with hyphens or the `_` nesting divider (no spaces or other special characters)" : "Slug must be lowercase, with hyphens (no spaces or special characters)";
218
223
  }
219
224
  return true;
220
225
  }
@@ -426,7 +431,8 @@ var link = (options = {}) => {
426
431
  disableLabel = false,
427
432
  relationTo = [PAGES_SLUG],
428
433
  localized = false,
429
- overrides = {}
434
+ overrides = {},
435
+ extraFields = []
430
436
  } = options;
431
437
  const result = {
432
438
  name: "link",
@@ -503,6 +509,9 @@ var link = (options = {}) => {
503
509
  options: opts
504
510
  });
505
511
  }
512
+ if (extraFields.length) {
513
+ result.fields.push(...extraFields);
514
+ }
506
515
  return { ...result, ...overrides };
507
516
  };
508
517
 
@@ -522,17 +531,22 @@ function createRevalidateGlobalHook(slug) {
522
531
 
523
532
  // src/data/collections/globals/Header/config.ts
524
533
  var createHeaderGlobal = (options = {}) => {
525
- const { renderPath = HEADER_RENDER_PATH, linkRelationTo = [PAGES_SLUG] } = options;
534
+ const {
535
+ renderPath = HEADER_RENDER_PATH,
536
+ linkRelationTo = [PAGES_SLUG],
537
+ navColumnLinkFields = [],
538
+ navItemLinkFields = []
539
+ } = options;
526
540
  const navColumnBlock = {
527
541
  slug: "navColumn",
528
542
  fields: [
529
543
  { name: "title", type: "text", required: true, localized: true },
530
- { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })] }
544
+ { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navColumnLinkFields })] }
531
545
  ]
532
546
  };
533
547
  const navItemBlock = {
534
548
  slug: "navItem",
535
- fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })]
549
+ fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navItemLinkFields })]
536
550
  };
537
551
  return {
538
552
  slug: "header",
@@ -554,17 +568,22 @@ var createHeaderGlobal = (options = {}) => {
554
568
 
555
569
  // src/data/collections/globals/Footer/config.ts
556
570
  var createFooterGlobal = (options = {}) => {
557
- const { renderPath = FOOTER_RENDER_PATH, linkRelationTo = [PAGES_SLUG] } = options;
571
+ const {
572
+ renderPath = FOOTER_RENDER_PATH,
573
+ linkRelationTo = [PAGES_SLUG],
574
+ navColumnLinkFields = [],
575
+ navItemLinkFields = []
576
+ } = options;
558
577
  const navColumnBlock = {
559
578
  slug: "navColumn",
560
579
  fields: [
561
580
  { name: "title", type: "text", required: true, localized: true },
562
- { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })] }
581
+ { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navColumnLinkFields })] }
563
582
  ]
564
583
  };
565
584
  const navItemBlock = {
566
585
  slug: "navItem",
567
- fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })]
586
+ fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navItemLinkFields })]
568
587
  };
569
588
  return {
570
589
  slug: "footer",
@@ -762,6 +781,7 @@ function createSitemapFile(options) {
762
781
  const collectionDefaults = options.perCollection?.[collectionSlug] ?? {};
763
782
  const urlPrefix = (options.urlPrefixes?.[collectionSlug] ?? "").replace(/\/$/, "");
764
783
  const siteUrl = options.getServerSideURL().replace(/\/$/, "");
784
+ const isNested = options.nested?.[collectionSlug] ?? false;
765
785
  for (const locale of activeLocales) {
766
786
  const docs = await queryAllDocs({
767
787
  collectionSlug,
@@ -777,13 +797,14 @@ function createSitemapFile(options) {
777
797
  if (seen.has(dedupeKey))
778
798
  continue;
779
799
  seen.add(dedupeKey);
780
- const url = `${siteUrl}${prefixFor(locale, defaultLocale, options.localePrefix ?? "always")}${urlPrefix}/${storedSlug}`;
800
+ const slugPath = isNested ? storedSlug.replaceAll("_", "/") : storedSlug;
801
+ const url = `${siteUrl}${prefixFor(locale, defaultLocale, options.localePrefix ?? "always")}${urlPrefix}/${slugPath}`;
781
802
  const languages = await buildHreflangAlternates({
782
803
  siteUrl,
783
804
  locale,
784
805
  urlPrefix,
785
806
  storedSlug,
786
- nested: false,
807
+ nested: isNested,
787
808
  homeSlug: "",
788
809
  defaultLocale,
789
810
  locales: activeLocales,
@@ -814,7 +835,7 @@ function createSitemapFile(options) {
814
835
  }
815
836
  // src/config/createWWWConfig.ts
816
837
  function createWWWConfig(options) {
817
- const { locales, routing, blocks, linkRelationTo, registerPosts = true, defaultPlugins } = options;
838
+ const { locales, routing, blocks, linkRelationTo, registerPosts = true, nested = false, defaultPlugins } = options;
818
839
  const defaultLocale = routing?.defaultLocale ?? locales[0] ?? "";
819
840
  const localePrefixMode = (() => {
820
841
  const raw = routing?.localePrefix;
@@ -827,7 +848,8 @@ function createWWWConfig(options) {
827
848
  }
828
849
  const buildPagesCollection = () => createPagesCollection(blocks, {
829
850
  localePrefix: localePrefixMode,
830
- defaultLocale
851
+ defaultLocale,
852
+ nested
831
853
  });
832
854
  const buildPostsCollection = () => createPostsCollection({
833
855
  localePrefix: localePrefixMode,
@@ -58,6 +58,14 @@ type WWWConfigOptions = {
58
58
  */
59
59
  registerPosts?: boolean;
60
60
  /**
61
+ * Enable nested (hierarchical) Pages slugs. When `true`, the lib's
62
+ * Pages collection accepts the `_` nesting divider in its slug
63
+ * (`about_us` → `/about/us`). Pass the same `nested: true` to
64
+ * `createCollectionPageExports` on the Pages route so URL building,
65
+ * hreflang, and static params expand the divider. Default: `false`.
66
+ */
67
+ nested?: boolean;
68
+ /**
61
69
  * Final say on the lib's default plugin list. Receives the
62
70
  * `[seoPlugin, imageHashPlugin, translator]` array; return the
63
71
  * list to apply. Common uses: drop the translator (no AI
@@ -195,20 +195,25 @@ var createPagesCollection = (blocks, options = {}) => {
195
195
  renderPath = PAGES_RENDER_PATH,
196
196
  slug: collectionSlug = PAGES_SLUG2,
197
197
  localePrefix,
198
- defaultLocale
198
+ defaultLocale,
199
+ nested = false
199
200
  } = options;
201
+ const slugPattern = nested ? /^[a-z0-9_-]+$/ : /^[a-z0-9-]+$/;
200
202
  const slugField = {
201
203
  name: "slug",
202
204
  type: "text",
203
205
  required: true,
204
206
  unique: true,
205
207
  index: true,
206
- admin: { position: "sidebar" },
208
+ admin: {
209
+ position: "sidebar",
210
+ ...nested ? { description: "Lowercase, hyphens for words. Use the `_` divider for nesting, e.g. `about_us` → `/about/us`." } : {}
211
+ },
207
212
  validate: (value) => {
208
213
  if (typeof value !== "string")
209
214
  return "Slug must be a string";
210
- if (value !== "" && !/^[a-z0-9-]+$/.test(value)) {
211
- return "Slug must be lowercase, with hyphens (no spaces or special characters)";
215
+ if (value !== "" && !slugPattern.test(value)) {
216
+ return nested ? "Slug must be lowercase, with hyphens or the `_` nesting divider (no spaces or other special characters)" : "Slug must be lowercase, with hyphens (no spaces or special characters)";
212
217
  }
213
218
  return true;
214
219
  }
@@ -420,7 +425,8 @@ var link = (options = {}) => {
420
425
  disableLabel = false,
421
426
  relationTo = [PAGES_SLUG],
422
427
  localized = false,
423
- overrides = {}
428
+ overrides = {},
429
+ extraFields = []
424
430
  } = options;
425
431
  const result = {
426
432
  name: "link",
@@ -497,6 +503,9 @@ var link = (options = {}) => {
497
503
  options: opts
498
504
  });
499
505
  }
506
+ if (extraFields.length) {
507
+ result.fields.push(...extraFields);
508
+ }
500
509
  return { ...result, ...overrides };
501
510
  };
502
511
 
@@ -516,17 +525,22 @@ function createRevalidateGlobalHook(slug) {
516
525
 
517
526
  // src/data/collections/globals/Header/config.ts
518
527
  var createHeaderGlobal = (options = {}) => {
519
- const { renderPath = HEADER_RENDER_PATH, linkRelationTo = [PAGES_SLUG] } = options;
528
+ const {
529
+ renderPath = HEADER_RENDER_PATH,
530
+ linkRelationTo = [PAGES_SLUG],
531
+ navColumnLinkFields = [],
532
+ navItemLinkFields = []
533
+ } = options;
520
534
  const navColumnBlock = {
521
535
  slug: "navColumn",
522
536
  fields: [
523
537
  { name: "title", type: "text", required: true, localized: true },
524
- { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })] }
538
+ { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navColumnLinkFields })] }
525
539
  ]
526
540
  };
527
541
  const navItemBlock = {
528
542
  slug: "navItem",
529
- fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })]
543
+ fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navItemLinkFields })]
530
544
  };
531
545
  return {
532
546
  slug: "header",
@@ -548,17 +562,22 @@ var createHeaderGlobal = (options = {}) => {
548
562
 
549
563
  // src/data/collections/globals/Footer/config.ts
550
564
  var createFooterGlobal = (options = {}) => {
551
- const { renderPath = FOOTER_RENDER_PATH, linkRelationTo = [PAGES_SLUG] } = options;
565
+ const {
566
+ renderPath = FOOTER_RENDER_PATH,
567
+ linkRelationTo = [PAGES_SLUG],
568
+ navColumnLinkFields = [],
569
+ navItemLinkFields = []
570
+ } = options;
552
571
  const navColumnBlock = {
553
572
  slug: "navColumn",
554
573
  fields: [
555
574
  { name: "title", type: "text", required: true, localized: true },
556
- { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })] }
575
+ { name: "links", type: "array", fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navColumnLinkFields })] }
557
576
  ]
558
577
  };
559
578
  const navItemBlock = {
560
579
  slug: "navItem",
561
- fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo })]
580
+ fields: [link({ appearances: false, localized: true, relationTo: linkRelationTo, extraFields: navItemLinkFields })]
562
581
  };
563
582
  return {
564
583
  slug: "footer",
@@ -756,6 +775,7 @@ function createSitemapFile(options) {
756
775
  const collectionDefaults = options.perCollection?.[collectionSlug] ?? {};
757
776
  const urlPrefix = (options.urlPrefixes?.[collectionSlug] ?? "").replace(/\/$/, "");
758
777
  const siteUrl = options.getServerSideURL().replace(/\/$/, "");
778
+ const isNested = options.nested?.[collectionSlug] ?? false;
759
779
  for (const locale of activeLocales) {
760
780
  const docs = await queryAllDocs({
761
781
  collectionSlug,
@@ -771,13 +791,14 @@ function createSitemapFile(options) {
771
791
  if (seen.has(dedupeKey))
772
792
  continue;
773
793
  seen.add(dedupeKey);
774
- const url = `${siteUrl}${prefixFor(locale, defaultLocale, options.localePrefix ?? "always")}${urlPrefix}/${storedSlug}`;
794
+ const slugPath = isNested ? storedSlug.replaceAll("_", "/") : storedSlug;
795
+ const url = `${siteUrl}${prefixFor(locale, defaultLocale, options.localePrefix ?? "always")}${urlPrefix}/${slugPath}`;
775
796
  const languages = await buildHreflangAlternates({
776
797
  siteUrl,
777
798
  locale,
778
799
  urlPrefix,
779
800
  storedSlug,
780
- nested: false,
801
+ nested: isNested,
781
802
  homeSlug: "",
782
803
  defaultLocale,
783
804
  locales: activeLocales,
@@ -808,7 +829,7 @@ function createSitemapFile(options) {
808
829
  }
809
830
  // src/config/createWWWConfig.ts
810
831
  function createWWWConfig(options) {
811
- const { locales, routing, blocks, linkRelationTo, registerPosts = true, defaultPlugins } = options;
832
+ const { locales, routing, blocks, linkRelationTo, registerPosts = true, nested = false, defaultPlugins } = options;
812
833
  const defaultLocale = routing?.defaultLocale ?? locales[0] ?? "";
813
834
  const localePrefixMode = (() => {
814
835
  const raw = routing?.localePrefix;
@@ -821,7 +842,8 @@ function createWWWConfig(options) {
821
842
  }
822
843
  const buildPagesCollection = () => createPagesCollection(blocks, {
823
844
  localePrefix: localePrefixMode,
824
- defaultLocale
845
+ defaultLocale,
846
+ nested
825
847
  });
826
848
  const buildPostsCollection = () => createPostsCollection({
827
849
  localePrefix: localePrefixMode,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justanarthur/payload-www",
3
3
  "description": "Reusable Payload CMS website template — config builder, collections, globals, blocks, fields, access, hooks, metadata (JSON-LD, hreflang), page renderers, and test helpers.",
4
- "version": "0.1.2",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "module": "./dist/index.js",