@morphemecms/cms 0.13.0-next-20261106212516 → 0.13.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/defaults/collections.ts +39 -52
  3. package/defaults/company.ts +5 -1
  4. package/defaults/editor.ts +27 -14
  5. package/defaults/i18n/da-DK/collections.ts +11 -8
  6. package/defaults/i18n/da-DK/editor.ts +1 -1
  7. package/defaults/i18n/da-DK/rules.ts +1 -0
  8. package/defaults/rules.ts +13 -1
  9. package/lib/focusTrap.d.mts +20 -0
  10. package/lib/focusTrap.mjs +28 -0
  11. package/lib/focusTrap.test.mjs +41 -0
  12. package/lib/folderChanges.mjs +1 -1
  13. package/lib/licenses.d.mts +1 -1
  14. package/lib/licenses.imports.test.mjs +33 -0
  15. package/lib/licenses.mjs +7 -4
  16. package/lib/menu.mjs +9 -4
  17. package/lib/menu.test.mjs +45 -18
  18. package/lib/storeEntries.d.mts +0 -1
  19. package/lib/storeEntries.mjs +2 -4
  20. package/lib/storeEntries.test.mjs +1 -6
  21. package/package.json +1 -1
  22. package/scripts/starter-gitattributes.test.mjs +22 -0
  23. package/site/header.d.mts +2 -1
  24. package/site/header.mjs +7 -4
  25. package/site/header.test.mjs +8 -2
  26. package/site/social.d.mts +65 -8
  27. package/site/social.mjs +132 -14
  28. package/site/social.test.mjs +109 -5
  29. package/src/App.tsx +29 -7
  30. package/src/AppList.tsx +46 -200
  31. package/src/BrandForm.tsx +2 -2
  32. package/src/CompanyForm.tsx +7 -2
  33. package/src/HeaderSettings.tsx +4 -4
  34. package/src/Library.tsx +20 -13
  35. package/src/LinkList.tsx +165 -0
  36. package/src/MainMenu.tsx +24 -4
  37. package/src/MoveButtons.tsx +15 -0
  38. package/src/Screen.tsx +12 -3
  39. package/src/StoreSearchRow.tsx +67 -0
  40. package/src/StoreStatus.tsx +67 -0
  41. package/src/ThemeStore.tsx +16 -37
  42. package/src/ThemeStoreScreen.tsx +62 -0
  43. package/src/checkSite.ts +3 -2
  44. package/src/cmsNotice.d.mts +7 -0
  45. package/src/cmsNotice.mjs +11 -0
  46. package/src/cmsNotice.test.mjs +50 -0
  47. package/src/fields.tsx +38 -13
  48. package/src/headerPreview.d.mts +14 -4
  49. package/src/headerPreview.mjs +10 -8
  50. package/src/headerPreview.test.mjs +12 -4
  51. package/src/headerTheme.tsx +19 -26
  52. package/src/i18n/da-DK/common.ts +1 -0
  53. package/src/i18n/da-DK/forms.ts +12 -0
  54. package/src/i18n/en-US/common.ts +2 -0
  55. package/src/i18n/en-US/forms.ts +15 -0
  56. package/src/levelStep.d.mts +10 -0
  57. package/src/levelStep.mjs +66 -0
  58. package/src/levelStep.test.mjs +119 -0
  59. package/src/linkAddress.d.mts +21 -0
  60. package/src/linkAddress.mjs +50 -0
  61. package/src/linkAddress.test.mjs +57 -0
  62. package/src/moveItem.d.mts +9 -0
  63. package/src/moveItem.mjs +17 -0
  64. package/src/moveItem.test.mjs +16 -0
  65. package/src/movedRoutes.mjs +8 -3
  66. package/src/movedRoutes.test.mjs +11 -2
  67. package/src/schema.ts +11 -0
  68. package/src/site.ts +1 -0
  69. package/src/store.ts +5 -33
  70. package/src/storeSort.tsx +39 -0
  71. package/src/themeFilter.d.mts +17 -0
  72. package/src/themeFilter.mjs +16 -0
  73. package/src/themeFilter.test.mjs +24 -0
  74. package/src/themeUi.ts +1 -0
  75. package/src/ui.tsx +2 -1
  76. package/starter/.gitattributes +2 -0
  77. package/starter/package.json +1 -1
  78. package/starter/src/data/social.json +1 -7
  79. package/theme/blocks/buttons/Buttons.tsx +12 -2
  80. package/theme/components/Header.astro +21 -31
  81. package/theme/components/HeaderCta.tsx +21 -0
  82. package/theme/components/HeaderPhoneButton.tsx +28 -0
  83. package/theme/components/MobileMenu.tsx +56 -43
  84. package/theme/components/headerParts.ts +5 -4
  85. package/theme/styles/global.css +22 -5
  86. package/theme/styles/ui.css +110 -7
  87. package/theme/ui/NavigationDrawer.tsx +6 -1
  88. package/src/legacyMove.test.mjs +0 -137
  89. package/src/legacyMove.ts +0 -131
package/CHANGELOG.md CHANGED
@@ -1,19 +1,30 @@
1
1
  # @morphemecms/cms
2
2
 
3
- ## 0.13.0-next-20261106212516
3
+ ## 0.13.0
4
4
 
5
5
  ### Minor Changes
6
6
 
7
7
  - 1193da4: The standard parts and their ids are in English. Danish comes from `defaults/i18n/da-DK/`. A site that overrides a standard part by its old id must use the new id. Old editor links and bookmarks still open (they redirect to the new address). The filters and sorts that the editor saved in the browser reset once.
8
+ - 10276ee: The header's "Call to action" can show the phone number (from Business › Contact; it calls on a phone and leads to the contact page on larger screens) as a text button before its button, and both share a row at the bottom of the menu; the menu opens from the side its button is on, and no drawer (site or editor) shows a gap at the screen's edge when its spring overshoots. In the editor, the back gesture goes from a section's list to the main menu, Header and Footer are under Appearance, and Business is a section of its own after Blocks and Apps.
8
9
  - 9ddc437: A list item can be selected with a long press on a phone (`selectable` on the theme's `ListItem`); in the editor's notifications this selects notifications to delete.
9
10
  - 32b4395: The bell in the editor's top bar opens the notifications in a side sheet over the screen you are on, instead of a screen of their own.
11
+ - 774ab69: Social media profiles are a list of links added with the + button; a link to Facebook, X, LinkedIn or Pinterest gets its name and icon from the address, and the share buttons are under their own heading.
10
12
  - 82d64b7: The start screen has a "Continue where you left off" card with the last three pages and blog posts saved, a notice of what was updated since your last visit (each with a close button), and the licenses link sits in a footer at the bottom of every screen.
13
+ - 03ad8a9: Move the theme store from the Appstore to its own screen, Theme store, first under Appearance.
11
14
 
12
15
  ### Patch Changes
13
16
 
17
+ - ccefd29: The licence checks know `@app/self` (app contract 3): an app that imports it no longer needs it in `thirdParty`, and
18
+ the site's licence list counts its virtual module as the site's own.
19
+ - e9d6b34: The editor's notifications also show when the website updated Morpheme from npm, undid an update or got stuck.
14
20
  - a283bcc: The drawer's divider stands level with the top bar's line.
21
+ - 6fa3043: The store no longer moves old store copies; all sites use permanent ids.
15
22
  - 9c24224: The text editor's empty-line hint says "Write text here…" again, on a line no taller than the hint.
23
+ - 697a7dc: List items follow Material 3's focus layer, corners and hover colours.
24
+ - 8fd74d0: Editor menu: Header and Footer come right after Themes under Appearance, and Logo and icon is under Business.
25
+ - 3a6157b: New sites get a `.gitattributes` that keeps LF line endings on Windows.
16
26
  - 3961b6d: The text toolbar no longer scrolls sideways on a phone: the tools that do not fit are on a second row, shown with the arrow at its end.
27
+ - 34f69c5: Open website, in the editor's top bar, opens the site in a new window instead of a new tab.
17
28
 
18
29
  ## 0.12.0
19
30
 
@@ -13,7 +13,7 @@ import { config } from '@site/config';
13
13
  import { standardPaths } from '@site/standardPages';
14
14
 
15
15
  // The fields of one button of the action bar (the bottom bar on a phone, ActionBar.astro) and of the button in the header
16
- // ("Header button", src/data/header.json): its text, where it leads, and the promotion or page it leads to.
16
+ // ("Call to action", src/data/header.json): its text, where it leads, and the promotion or page it leads to.
17
17
  const barButtonLabel: Field = {
18
18
  name: 'label',
19
19
  label: 'Button text',
@@ -218,59 +218,36 @@ export const contactFields: Field[] = [
218
218
  },
219
219
  ];
220
220
 
221
- // The social media (src/data/social.json, a part of its own under "Business", company.ts): the site's profiles, and
222
- // which share buttons a post shows. A switch that is not in the file is on (company.ts gives the form that default).
223
- // A profile address: empty, or one that begins with https://.
224
- const httpsAddress: [string, string] = ['^$|^https://\\S+$', 'Write an address that starts with https://'];
225
-
221
+ // The social media (src/data/social.json, a part of its own under "Business", company.ts): the site's profiles, a list of
222
+ // links added with the FAB, each one's network and icon known by its address (networkOf in site/social.mjs), and which
223
+ // share buttons a post shows. A switch that is not in the file is on (company.ts gives the form that default).
226
224
  export const socialFields: Field[] = [
227
225
  {
228
- name: 'facebook',
229
- label: 'Facebook',
230
- type: 'string',
231
- hint: "The address of the site's profile. Shown in Google's data about the business.",
232
- pattern: httpsAddress,
233
- },
234
- {
235
- name: 'instagram',
236
- label: 'Instagram',
237
- type: 'string',
238
- pattern: httpsAddress,
239
- },
240
- {
241
- name: 'linkedin',
242
- label: 'LinkedIn',
243
- type: 'string',
244
- pattern: httpsAddress,
245
- },
246
- {
247
- name: 'x',
248
- label: 'X',
249
- type: 'string',
250
- pattern: httpsAddress,
251
- },
252
- {
253
- name: 'youtube',
254
- label: 'YouTube',
255
- type: 'string',
256
- pattern: httpsAddress,
257
- },
258
- {
259
- name: 'tiktok',
260
- label: 'TikTok',
261
- type: 'string',
262
- pattern: httpsAddress,
226
+ name: 'profiles',
227
+ label: 'Profiles',
228
+ type: 'list',
229
+ links: true,
230
+ hint: "The site's profiles on social media. Shown in Google's data about the business.",
231
+ summary: 'url',
232
+ fields: [
233
+ {
234
+ name: 'url',
235
+ label: 'Address',
236
+ type: 'string',
237
+ required: true,
238
+ pattern: ['^https://\\S+$', 'Write an address that starts with https://'],
239
+ },
240
+ ],
263
241
  },
264
242
  {
265
- name: 'pinterest',
266
- label: 'Pinterest',
267
- type: 'string',
268
- pattern: httpsAddress,
243
+ name: 'shareFacebook',
244
+ label: 'Facebook',
245
+ type: 'boolean',
246
+ heading: { label: 'Share buttons', hint: 'The buttons at the top of each blog post.' },
269
247
  },
270
- { name: 'shareFacebook', label: 'Share on Facebook', type: 'boolean' },
271
- { name: 'shareX', label: 'Share on X', type: 'boolean' },
272
- { name: 'shareLinkedin', label: 'Share on LinkedIn', type: 'boolean' },
273
- { name: 'sharePinterest', label: 'Share on Pinterest', type: 'boolean', hint: 'Shown only when the post has a picture.' },
248
+ { name: 'shareX', label: 'X', type: 'boolean' },
249
+ { name: 'shareLinkedin', label: 'LinkedIn', type: 'boolean' },
250
+ { name: 'sharePinterest', label: 'Pinterest', type: 'boolean', hint: 'Shown only when the post has a picture.' },
274
251
  { name: 'shareCopyLink', label: 'Copy link', type: 'boolean' },
275
252
  ];
276
253
 
@@ -869,22 +846,32 @@ export const collections: Collection[] = [
869
846
  optional: true,
870
847
  fields: [
871
848
  {
849
+ // The call to action: the button and the site's phone number (contact settings) as a text button before it, in
850
+ // the header and at the bottom of the navigation drawer (Header.astro, MobileMenu.tsx; site/header.mjs).
872
851
  name: 'button',
873
- label: 'Action button',
852
+ label: 'Call to action',
874
853
  type: 'object',
875
854
  fields: [
876
855
  {
877
856
  name: 'enabled',
878
- label: 'Activated',
857
+ label: 'Button',
879
858
  type: 'boolean',
880
859
  toggle: ['On', 'Off'],
881
860
  default: false,
882
- hint: 'The button is in the header.',
861
+ hint: 'The button is in the header and at the bottom of the menu.',
883
862
  },
884
863
  barButtonLabel,
885
864
  barButtonTo,
886
865
  barButtonOpen,
887
866
  barButtonHref,
867
+ {
868
+ name: 'phone',
869
+ label: 'Phone number',
870
+ type: 'boolean',
871
+ toggle: ['On', 'Off'],
872
+ default: true,
873
+ hint: 'The phone number from the contact settings, before the button in the header and at the bottom of the menu.',
874
+ },
888
875
  ],
889
876
  },
890
877
  {
@@ -1,11 +1,13 @@
1
1
  // "Business": the business, in parts, each a screen of its own (CompanyForm.tsx): its phone, e-mail and address
2
2
  // (src/data/contact.json) and its bank details (src/data/payment.json, under "Payment", where a site may add its
3
3
  // other ways to pay, and where the settings of the apps installed that are a way to pay come after, as MobilePay's),
4
- // used all over the site; and its social media (src/data/social.json): the profiles and the post's share buttons.
4
+ // used all over the site; and its social media (src/data/social.json): the profiles, a list of links added with the FAB, and
5
+ // the post's share buttons.
5
6
  // The logo and the icon are under "Appearance" (BrandForm.tsx); the link to the calendar in Calendly is the app Calendly's (morpheme-apps/apps/calendly/).
6
7
  import { AddressBook, Bank, CreditCard, ShareNetwork } from '@phosphor-icons/react';
7
8
  import type { Part } from '../src/schema';
8
9
  import { bankFields, contactFields, socialFields } from '@site/collections';
10
+ import { upgradeSocial } from '../site/social.mjs';
9
11
 
10
12
  /** The parts of "Business", in their order, at #company/<id> ("Payment"'s at #company/payment/<id>). */
11
13
  export const companyParts: Part[] = [
@@ -16,6 +18,8 @@ export const companyParts: Part[] = [
16
18
  icon: ShareNetwork,
17
19
  file: 'src/data/social.json',
18
20
  fields: socialFields,
21
+ // A file from before the profiles were a list (a key for each network) is read and saved as one.
22
+ upgrade: upgradeSocial,
19
23
  // A switch missing from the file (or a file not there yet) is on; the form would show it off without this.
20
24
  defaults: { shareFacebook: true, shareX: true, shareLinkedin: true, sharePinterest: true, shareCopyLink: true },
21
25
  },
@@ -3,8 +3,8 @@
3
3
  // standard parts: a site has them unless its src/site/ has a file of the same name (siteFile in site/theme.mjs), which
4
4
  // may take the standard one from @morphemecms/cms/defaults/<file> and add its own.
5
5
  // collections the parts of the site and their fields (collections.ts)
6
- // groups, settings, lists, header, icons the menu and the start screen
7
- // appearance the site's own parts under "Appearance", after the logo and icon ("Default pages")
6
+ // groups, settings, lists, header, icons the menu and the start screen ("Appearance" starts with "Theme store", the store's themes)
7
+ // appearance the site's own parts under "Appearance", after the colours ("Default pages")
8
8
  // companyParts, blockParts the screens under "Business" and "Blocks" (company.ts, blockTexts.ts)
9
9
  // partScreens parts of the start screen that are screens with parts of their own (privacy.ts)
10
10
  // seoParts the site's own parts under "Search engine optimization" (#seo/<id>), in the menu under "Content" (internalLinks.ts)
@@ -112,7 +112,7 @@ export const icons: Record<string, Icon> = {
112
112
  tags: Tag,
113
113
  popups: Megaphone,
114
114
  company: Buildings,
115
- // The sections of the main menu that are not also a part.
115
+ // The sections of the main menu that are not also a part ("Business" is the part "company"'s icon).
116
116
  content: Article,
117
117
  global: GlobeSimple,
118
118
  settings: GearSix,
@@ -127,10 +127,11 @@ export const icons: Record<string, Icon> = {
127
127
  'block-settings': ToggleRight,
128
128
  apps: PuzzlePiece,
129
129
  appstore: Storefront,
130
+ 'theme-store': Storefront,
130
131
  morpheme: Sparkle,
131
132
  advanced: Wrench,
132
133
  licenses: Certificate,
133
- // The screens under "Appearance" (Library.tsx Theme).
134
+ // The screens under "Appearance" (Library.tsx Theme), the first the store's themes.
134
135
  themes: Swatches,
135
136
  colors: Palette,
136
137
  logo: Sticker,
@@ -140,7 +141,7 @@ export const icons: Record<string, Icon> = {
140
141
  parts: Shapes,
141
142
  };
142
143
 
143
- /** The site's own parts under "Appearance" (#appearance), after the logo and icon, each a screen of its own at #appearance/<id>:
144
+ /** The site's own parts under "Appearance" (#appearance), after the colours, each a screen of its own at #appearance/<id>:
144
145
  * "Default pages", which pages are the front page, the blog page, the 404 page and the contact page, as a list
145
146
  * (standardPageFieldsOf in collections.ts; the privacy policy and the cookie policy are under "Privacy", privacy.ts). */
146
147
  export const appearance: Part[] = [
@@ -157,12 +158,13 @@ export const appearance: Part[] = [
157
158
  // The editor's menu: eight sections in a fixed order, shown in the order written here, never A–Z. "Dashboard" is a link to the
158
159
  // start screen (`href`) without a list; the others have a list of parts, each an id: the content the owner writes and
159
160
  // uploads ("Content": the pages, the blog, the short lists, the pop-ups, the tags, the files, "internal links" (seoParts)
160
- // and "redirects", Redirects.tsx), what is on every page ("Globals": the header with the menus, the footer and
161
- // "Business": name, contact and booking), the apps ("Apps": the apps that come with the site, src/apps/, AppList.tsx,
162
- // and "appstore"), the blocks ("Blocks": their shared texts, and "Block selection", which of them can be put in), how the site
163
- // looks ("Appearance": the theme, the icon packs, the motion, the side column (SideColumn.tsx), the colors, the logo and icon, the standard pages
164
- // (appearance) and the editor's parts: Colors.tsx, BrandForm.tsx and Parts.tsx), the parts of "Privacy" (privacy.ts)
165
- // and the settings of the whole site ("Settings").
161
+ // and "redirects", Redirects.tsx), the blocks ("Blocks": their shared texts, and "Block selection", which of them can be
162
+ // put in), the apps ("Apps": the apps that come with the site, src/apps/, AppList.tsx, and "appstore"), the business
163
+ // ("Business": the logo and icon first (company/logo, BrandForm.tsx), then the section of the editor's part "company": its parts, name, contact and booking, are its rows; lib/menu.mjs),
164
+ // how the site looks ("Appearance": the theme store, the header with the menus and the footer (what is on every page), the theme,
165
+ // the icon packs, the motion, the side column (SideColumn.tsx), the colors, the standard pages (appearance)
166
+ // and the editor's parts: Colors.tsx, BrandForm.tsx and Parts.tsx), the parts of "Privacy" (privacy.ts) and the settings
167
+ // of the whole site ("Settings").
166
168
  // Each part is a collection (collections.ts), except those named above, "privacy"'s (partScreens) and the editor's own
167
169
  // "company" (companyParts), "blocks" (blockParts), id files (the pictures, PDFs, videos and zip files), "header", "lists"
168
170
  // and "block-settings" (BlockSettings.tsx).
@@ -174,13 +176,24 @@ export const groups: { id: string; title: string; href?: string; ids: string[] }
174
176
  title: 'Content',
175
177
  ids: ['pages', 'blog', 'lists', 'popups', 'tags', 'files', internalLinks.id, 'redirects'],
176
178
  },
177
- { id: 'global', title: 'Globals', ids: ['header', 'footer', 'company'] },
178
- { id: 'apps', title: 'Apps', ids: ['apps', 'appstore'] },
179
179
  { id: 'blocks', title: 'Blocks', ids: ['blocks', 'block-settings'] },
180
+ { id: 'apps', title: 'Apps', ids: ['apps', 'appstore'] },
181
+ { id: 'company', title: 'Business', ids: ['logo', 'company'] },
180
182
  {
181
183
  id: 'appearance',
182
184
  title: 'Appearance',
183
- ids: ['themes', 'icon-packs', 'motion', 'side-column', 'colors', 'logo', ...appearance.map((p) => p.id), 'parts'],
185
+ ids: [
186
+ 'theme-store',
187
+ 'header',
188
+ 'footer',
189
+ 'themes',
190
+ 'icon-packs',
191
+ 'motion',
192
+ 'side-column',
193
+ 'colors',
194
+ ...appearance.map((p) => p.id),
195
+ 'parts',
196
+ ],
184
197
  },
185
198
  { id: 'privacy', title: 'Privacy', ids: privacy.parts.map((p) => p.id) },
186
199
  { id: 'settings', title: 'Settings', ids: settingsIds },
@@ -51,12 +51,12 @@ export const collections: Record<string, string> = {
51
51
  'CVR number': 'CVR-nummer',
52
52
  'Shown at the bottom of every page.': 'Vises nederst på hver side.',
53
53
  'A CVR number has eight digits': 'Et CVR-nummer har otte cifre',
54
- "The address of the site's profile. Shown in Google's data about the business.":
55
- 'Adressen på sitets profil. Vises i Googles data om virksomheden.',
56
- 'Share on Facebook': 'Del på Facebook',
57
- 'Share on X': 'Del på X',
58
- 'Share on LinkedIn': 'Del på LinkedIn',
59
- 'Share on Pinterest': 'Del på Pinterest',
54
+ Profiles: 'Profiler',
55
+ Address: 'Adresse',
56
+ "The site's profiles on social media. Shown in Google's data about the business.":
57
+ 'Sitets profiler på sociale medier. Vises i Googles data om virksomheden.',
58
+ 'Share buttons': 'Delingsknapper',
59
+ 'The buttons at the top of each blog post.': 'Knapperne øverst i hvert blogindlæg.',
60
60
  'Shown only when the post has a picture.': 'Vises kun, når indlægget har et billede.',
61
61
  'Copy link': 'Kopiér link',
62
62
  'Reg. no.': 'Reg.nr.',
@@ -192,8 +192,11 @@ export const collections: Record<string, string> = {
192
192
  'header button': 'headerknap',
193
193
  'Header button': 'Headerknap',
194
194
  'The header': 'Headeren',
195
- 'Action button': 'Handlingsknap',
196
- 'The button is in the header.': 'Knappen står i headeren.',
195
+ 'Call to action': 'Opfordring til handling',
196
+ 'The button is in the header and at the bottom of the menu.': 'Knappen står i headeren og nederst i menuen.',
197
+ 'Phone number': 'Telefonnummer',
198
+ 'The phone number from the contact settings, before the button in the header and at the bottom of the menu.':
199
+ 'Telefonnummeret fra kontaktoplysningerne, før knappen i headeren og nederst i menuen.',
197
200
  'Menu button position': 'Menuknappens placering',
198
201
  Left: 'Venstre',
199
202
  Right: 'Højre',
@@ -4,8 +4,8 @@ export const editor: Record<string, string> = {
4
4
  'Default pages': 'Standardsider',
5
5
  Dashboard: 'Oversigt',
6
6
  Content: 'Indhold',
7
- Globals: 'Globalt',
8
7
  Blocks: 'Blokke',
8
+ Business: 'Virksomheden',
9
9
  Appearance: 'Udseende',
10
10
  Privacy: 'Privatliv',
11
11
  Settings: 'Indstillinger',
@@ -15,6 +15,7 @@ export const rules: Record<string, string> = {
15
15
  'Edit the action bar': 'Rediger handlingslinjen',
16
16
  'Edit the default buttons': 'Rediger standardknapperne',
17
17
  'The default pages must be different pages.': 'Standardsiderne skal være forskellige sider.',
18
+ 'The address {url} is in the list of profiles more than once.': 'Adressen {url} er flere gange på listen over profiler.',
18
19
  'The page "{side}" does not have the block {blok}, which {hvad} must have. Put it in on the page first.':
19
20
  'Siden "{side}" har ikke blokken {blok}, som {hvad} skal have. Sæt den ind på siden først.',
20
21
  '{side} must be a published page.': '{side} skal være en side, der er udgivet.',
package/defaults/rules.ts CHANGED
@@ -15,6 +15,7 @@ import { collections } from '@site/collections';
15
15
  import { config } from '@site/config';
16
16
  import { opensOf } from '@site/opens';
17
17
  import { differentPages, standardPages, standardPaths } from '@site/standardPages';
18
+ import { twiceIn, upgradeSocial } from '../site/social.mjs';
18
19
 
19
20
  const fm = (file: RepoFile) => readFields(splitFrontmatter(file.text).yaml);
20
21
  // A page's address: `path`, or /<file name>/ for pages made in the editor.
@@ -226,7 +227,7 @@ function standardKept(data: Record<string, unknown>, files: RepoFile[]): Status
226
227
  }
227
228
 
228
229
  /** What is wrong on the site by its own rules, a line for each, before it is built (checkSite.ts): a standard page that
229
- * is not a page on the site, or the same page twice, and a button that opens a promotion that is gone, turned off or not
230
+ * is not a page on the site, or the same page twice, a profile on social media twice, and a button that opens a promotion that is gone, turned off or not
230
231
  * shown on its page. */
231
232
  export function siteProblems(files: { path: string; text: string }[]): string[] {
232
233
  const problems: string[] = [];
@@ -241,6 +242,17 @@ export function siteProblems(files: { path: string; text: string }[]): string[]
241
242
  if (!differentPages(standardOf(files)))
242
243
  problems.push(`${config.standardPages}: ${siteText('The default pages must be different pages.')}`);
243
244
  }
245
+ // A profile is on social media's list once (Business › Social media, social.json).
246
+ const social = files.find((f) => f.path === 'src/data/social.json');
247
+ if (social) {
248
+ try {
249
+ const profiles = upgradeSocial(JSON.parse(social.text) as Record<string, unknown>).profiles;
250
+ for (const url of twiceIn(Array.isArray(profiles) ? profiles : []))
251
+ problems.push(`${social.path}: ${siteText('The address {url} is in the list of profiles more than once.', { url })}`);
252
+ } catch {
253
+ // A file that is not JSON is found by the check of the files' own rules.
254
+ }
255
+ }
244
256
  const promotions = files.filter((f) => f.path.startsWith('src/data/popups/') && f.path.endsWith('.json'));
245
257
  const slugs = new Set<string>();
246
258
  for (const f of files) for (const m of f.text.matchAll(/(?:&quot;|")open(?:&quot;|"):\s*(?:&quot;|")([^"&]+)/g)) slugs.add(m[1]);
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The elements that are drawn now: one that is hidden (display: none, as the contact-page link is below the medium window
3
+ * size) has no boxes and cannot take the focus, so it is neither the first nor the last stop.
4
+ * @template {{ getClientRects(): { length: number } }} T
5
+ * @param {ArrayLike<T>} elements
6
+ * @returns {T[]}
7
+ */
8
+ export function visibleElements<T extends {
9
+ getClientRects(): {
10
+ length: number;
11
+ };
12
+ }>(elements: ArrayLike<T>): T[];
13
+ /**
14
+ * The index to focus when Tab would leave the list, or null when the browser may move the focus itself.
15
+ * @param {boolean} shift whether Shift is held
16
+ * @param {number} current the index of the focused element, -1 when it is not in the list
17
+ * @param {number} count how many stops there are
18
+ * @returns {number | null}
19
+ */
20
+ export function trapTarget(shift: boolean, current: number, count: number): number | null;
@@ -0,0 +1,28 @@
1
+ // Keeps Tab inside a modal drawer (theme/components/MobileMenu.tsx does the DOM). Exposes visibleElements and trapTarget;
2
+ // no dependencies.
3
+
4
+ /**
5
+ * The elements that are drawn now: one that is hidden (display: none, as the contact-page link is below the medium window
6
+ * size) has no boxes and cannot take the focus, so it is neither the first nor the last stop.
7
+ * @template {{ getClientRects(): { length: number } }} T
8
+ * @param {ArrayLike<T>} elements
9
+ * @returns {T[]}
10
+ */
11
+ export function visibleElements(elements) {
12
+ return Array.from(elements).filter((el) => el.getClientRects().length > 0);
13
+ }
14
+
15
+ /**
16
+ * The index to focus when Tab would leave the list, or null when the browser may move the focus itself.
17
+ * @param {boolean} shift whether Shift is held
18
+ * @param {number} current the index of the focused element, -1 when it is not in the list
19
+ * @param {number} count how many stops there are
20
+ * @returns {number | null}
21
+ */
22
+ export function trapTarget(shift, current, count) {
23
+ if (count === 0) return null;
24
+ if (current < 0) return shift ? count - 1 : 0;
25
+ if (shift && current === 0) return count - 1;
26
+ if (!shift && current === count - 1) return 0;
27
+ return null;
28
+ }
@@ -0,0 +1,41 @@
1
+ // Tests lib/focusTrap.mjs: which stops count in the drawer and where Tab wraps.
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { trapTarget, visibleElements } from './focusTrap.mjs';
5
+
6
+ const el = (name, drawn) => ({ name, getClientRects: () => (drawn ? [{}] : []) });
7
+
8
+ test('visibleElements drops the elements that are not drawn', () => {
9
+ const list = [el('a', true), el('hidden', false), el('b', true)];
10
+ assert.deepEqual(
11
+ visibleElements(list).map((e) => e.name),
12
+ ['a', 'b'],
13
+ );
14
+ });
15
+
16
+ test('the last stop is the last drawn one, not a hidden element after it', () => {
17
+ const list = visibleElements([el('first', true), el('last', true), el('hidden link', false)]);
18
+ assert.equal(trapTarget(false, 1, list.length), 0);
19
+ });
20
+
21
+ test('Tab on the last stop wraps to the first', () => {
22
+ assert.equal(trapTarget(false, 2, 3), 0);
23
+ });
24
+
25
+ test('Shift+Tab on the first stop wraps to the last', () => {
26
+ assert.equal(trapTarget(true, 0, 3), 2);
27
+ });
28
+
29
+ test('Tab inside the list is left to the browser', () => {
30
+ assert.equal(trapTarget(false, 0, 3), null);
31
+ assert.equal(trapTarget(true, 2, 3), null);
32
+ });
33
+
34
+ test('focus outside the list goes to its first or last stop', () => {
35
+ assert.equal(trapTarget(false, -1, 3), 0);
36
+ assert.equal(trapTarget(true, -1, 3), 2);
37
+ });
38
+
39
+ test('an empty list has no target', () => {
40
+ assert.equal(trapTarget(false, -1, 0), null);
41
+ });
@@ -1,5 +1,5 @@
1
1
  // The changes that make a folder in a site the store's version of an app or a theme, with its store.json note, or remove
2
- // it. Exposes folderChanges (used by src/store.ts and src/legacyMove.ts). Depends on ./storeEntries.mjs (RECORD).
2
+ // it. Exposes folderChanges (used by src/store.ts). Depends on ./storeEntries.mjs (RECORD).
3
3
  import { RECORD } from './storeEntries.mjs';
4
4
 
5
5
  /** @typedef {{ path: string, oid: string }} RepoFile
@@ -37,7 +37,7 @@ export function iconPackWorks(pack: IconPack, root: string): {
37
37
  export function systemPackages(): Set<any>;
38
38
  /** The packages a folder's code imports by name (`from 'motion'`, `import('…')`, CSS `@import '…'` and `@plugin '…'`;
39
39
  * not `import type`, which ships nothing), each with the files that import it: not node's own, the site's aliases
40
- * (@theme/, @site/, @data/, @saturn/) or Vite's and Astro's virtual modules. */
40
+ * (@theme/, @site/, @data/, @saturn/, an app's @app/self) or Vite's and Astro's virtual modules. */
41
41
  export function importsIn(folder: any): Map<any, any>;
42
42
  /** A work an app or a theme declares (an entry of its `thirdParty`), as "Licenser" shows it, with its licence's text:
43
43
  * an npm package's from its folder (its id its name), with the `author` and the licence `file` (in the app's or the
@@ -0,0 +1,33 @@
1
+ // Tests that importsIn skips @app/self and site aliases
2
+ // Run with: node --test lib/licenses.imports.test.mjs
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { importsIn } from './licenses.mjs';
9
+
10
+ const keysOf = (code) => {
11
+ const folder = mkdtempSync(join(tmpdir(), 'imports-'));
12
+ try {
13
+ writeFileSync(join(folder, 'a.ts'), code);
14
+ return [...importsIn(folder).keys()].sort();
15
+ } finally {
16
+ rmSync(folder, { recursive: true, force: true });
17
+ }
18
+ };
19
+
20
+ test("skips an app's @app/self", () => {
21
+ const code = `import self from '@app/self';\nimport { animate } from 'motion';\n`;
22
+ assert.deepEqual(keysOf(code), ['motion']);
23
+ });
24
+
25
+ test('lists another @app/ import as a package', () => {
26
+ const code = `import x from '@app/other';\n`;
27
+ assert.deepEqual(keysOf(code), ['@app/other']);
28
+ });
29
+
30
+ test('skips the site aliases', () => {
31
+ const code = `import t from '@theme/x';\nimport s from '@site/y';\n`;
32
+ assert.deepEqual(keysOf(code), []);
33
+ });
package/lib/licenses.mjs CHANGED
@@ -205,7 +205,7 @@ const builtins = new Set(builtinModules);
205
205
 
206
206
  /** The packages a folder's code imports by name (`from 'motion'`, `import('…')`, CSS `@import '…'` and `@plugin '…'`;
207
207
  * not `import type`, which ships nothing), each with the files that import it: not node's own, the site's aliases
208
- * (@theme/, @site/, @data/, @saturn/) or Vite's and Astro's virtual modules. */
208
+ * (@theme/, @site/, @data/, @saturn/, an app's @app/self) or Vite's and Astro's virtual modules. */
209
209
  export function importsIn(folder) {
210
210
  const found = new Map();
211
211
  if (!existsSync(folder)) return found;
@@ -225,7 +225,10 @@ export function importsIn(folder) {
225
225
  ...code.matchAll(/@(?:import|plugin)\s+(?:url\()?\s*['"]([^'"]+)['"]/g),
226
226
  ].map((m) => m[1]);
227
227
  for (const specifier of specifiers) {
228
- if (/^(\.|\/|node:|@theme\/|@site\/|@data\/|@saturn\/|virtual:|astro:|https?:|data:|\$)/.test(specifier) || specifier.startsWith('#'))
228
+ if (
229
+ /^(\.|\/|node:|@theme\/|@site\/|@data\/|@saturn\/|@app\/self$|virtual:|astro:|https?:|data:|\$)/.test(specifier) ||
230
+ specifier.startsWith('#')
231
+ )
229
232
  continue;
230
233
  const name = packageName(specifier);
231
234
  if (builtins.has(name)) continue;
@@ -423,12 +426,12 @@ function whereOf(file, root) {
423
426
  }
424
427
 
425
428
  /** The bundler's helpers that it writes into the bundle (Vite's and Rolldown's virtual modules): their package. The
426
- * site's own virtual modules (the apps' folders, the theme's icons and coverage) are made of its own files. */
429
+ * site's own virtual modules (the apps' folders and @app/self, the theme's icons and coverage) are made of its own files. */
427
430
  const HELPERS = [
428
431
  [/^\0vite\//, 'vite'],
429
432
  [/^\0rolldown\//, 'rolldown'],
430
433
  ];
431
- const OWN_VIRTUAL = [/^\0virtual:(app-folders|all-icons|material-symbols|theme-coverage)$/, /^\0site-theme-icons?(:|$)/];
434
+ const OWN_VIRTUAL = [/^\0virtual:(app-folders|all-icons|material-symbols|theme-coverage)$/, /^\0site-theme-icons?(:|$)/, /^\0app-self:/];
432
435
 
433
436
  /** An app or a theme as licensesIntegration takes it: its kind and id, its name, version and who made it, its manifest
434
437
  * (its defineApp or its theme.json, with `license` and `thirdParty`), its folder, and whether the site is built with it
package/lib/menu.mjs CHANGED
@@ -1,8 +1,12 @@
1
1
  // The editor's menu as data: the two-level tree (menuTree) and which section/item/child is open for a route (currentPlace).
2
- // In the Apps section the installed-apps list (`apps`) is not an item: each installed app is an item after the others.
2
+ // A section that is a part with a list of its own (Apps: the installed apps, `apps`; Business: the business's parts,
3
+ // `company`) has not that part as an item: each of its children is an item, after the others.
3
4
  // currentPart (which item is open for a route) is exported only for its tests; currentPlace uses it as its last rule.
4
5
  // Pure, no browser; MainMenu.tsx draws the drawer from it. No dependencies.
5
6
 
7
+ // The sections that are a part whose children are the section's rows.
8
+ const HOISTED = ['apps', 'company'];
9
+
6
10
  /**
7
11
  * @param {{ id: string, title: string, href?: string, ids: string[] }[]} groups
8
12
  * @param {(id: string) => { label: string, href: string, count: number | null }} partOf
@@ -15,9 +19,10 @@ export function menuTree(groups, partOf, childrenOf) {
15
19
  if (group.href !== undefined) {
16
20
  entry.href = group.href;
17
21
  }
18
- // The installed-apps list (#apps) is not a row of the Apps section: its apps are rows, after the other items.
19
- const hoisted = group.id === 'apps' ? childrenOf('apps') : [];
20
- const rows = group.id === 'apps' ? group.ids.filter((id) => id !== 'apps') : group.ids;
22
+ // The part the section is (#apps, #company) is not a row of it: its children are rows, after the other items.
23
+ const own = HOISTED.includes(group.id) && group.ids.includes(group.id);
24
+ const hoisted = own ? childrenOf(group.id) : [];
25
+ const rows = own ? group.ids.filter((id) => id !== group.id) : group.ids;
21
26
  entry.items = rows.map((id) => {
22
27
  const item = { id, ...partOf(id) };
23
28
  const children = childrenOf(id);