@nuxtseo/design-system 0.0.0-stage → 0.1.1

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 (219) hide show
  1. package/README.md +181 -2
  2. package/app/app.config.ts +592 -0
  3. package/app/component-attributes.d.ts +8 -0
  4. package/app/components/README.md +38 -0
  5. package/app/components/brand/UiBrandMark.vue +63 -0
  6. package/app/components/brand/UiModuleIcon.vue +45 -0
  7. package/app/components/brand/UiProLettering.vue +26 -0
  8. package/app/components/brand/UiRangeTerrain.vue +311 -0
  9. package/app/components/brand/range-glyphs.ts +18 -0
  10. package/app/components/container/BrowserTab.vue +145 -0
  11. package/app/components/container/IntegrationRow.vue +120 -0
  12. package/app/components/container/KnowDoRails.vue +40 -0
  13. package/app/components/container/RailPanel.vue +93 -0
  14. package/app/components/container/UiAlert.vue +120 -0
  15. package/app/components/container/UiAppShell.vue +165 -0
  16. package/app/components/container/UiCard.vue +204 -0
  17. package/app/components/container/UiDisclosure.vue +43 -0
  18. package/app/components/container/UiEmptyState.vue +102 -0
  19. package/app/components/container/UiErrorState.vue +61 -0
  20. package/app/components/container/UiLoadingState.vue +20 -0
  21. package/app/components/container/UiPageHeader.vue +95 -0
  22. package/app/components/container/UiPopover.vue +102 -0
  23. package/app/components/container/UiSampleDataOverlay.vue +47 -0
  24. package/app/components/container/UiSectionHeader.vue +83 -0
  25. package/app/components/container/UiSummaryStrip.vue +40 -0
  26. package/app/components/container/UiToast.vue +230 -0
  27. package/app/components/container/UiToaster.vue +271 -0
  28. package/app/components/container/UiWidgetState.vue +143 -0
  29. package/app/components/container/ui-nav-drawer.ts +48 -0
  30. package/app/components/data/ActionRail.vue +152 -0
  31. package/app/components/data/FacetStrip.vue +93 -0
  32. package/app/components/data/UiBarCell.vue +37 -0
  33. package/app/components/data/UiBarChart.vue +281 -0
  34. package/app/components/data/UiChartAnnotationControls.vue +74 -0
  35. package/app/components/data/UiChartAnnotations.vue +163 -0
  36. package/app/components/data/UiChartBrushOverlay.vue +96 -0
  37. package/app/components/data/UiChartDataReveal.vue +81 -0
  38. package/app/components/data/UiChartFrame.vue +123 -0
  39. package/app/components/data/UiChartTooltip.vue +81 -0
  40. package/app/components/data/UiCoverageNote.vue +90 -0
  41. package/app/components/data/UiCwvSparkline.vue +188 -0
  42. package/app/components/data/UiDataList.vue +276 -0
  43. package/app/components/data/UiDataTableSection.vue +233 -0
  44. package/app/components/data/UiDistributionBars.vue +164 -0
  45. package/app/components/data/UiDonutChart.vue +132 -0
  46. package/app/components/data/UiDoorwayColumn.vue +108 -0
  47. package/app/components/data/UiDoorwayGrid.vue +58 -0
  48. package/app/components/data/UiDoorwayRow.vue +83 -0
  49. package/app/components/data/UiEntitySummary.vue +261 -0
  50. package/app/components/data/UiFactsGrid.vue +107 -0
  51. package/app/components/data/UiImpactMeter.vue +33 -0
  52. package/app/components/data/UiIssueRow.vue +149 -0
  53. package/app/components/data/UiLineChart.vue +431 -0
  54. package/app/components/data/UiMetricBarPopover.vue +121 -0
  55. package/app/components/data/UiMetricLabel.vue +30 -0
  56. package/app/components/data/UiMetricStat.vue +73 -0
  57. package/app/components/data/UiMetricToggle.vue +156 -0
  58. package/app/components/data/UiMetricsRow.vue +64 -0
  59. package/app/components/data/UiPositionMetric.vue +53 -0
  60. package/app/components/data/UiRelatedFixes.vue +113 -0
  61. package/app/components/data/UiRichText.vue +325 -0
  62. package/app/components/data/UiScatterPlot.vue +144 -0
  63. package/app/components/data/UiSeverityMarker.vue +30 -0
  64. package/app/components/data/UiSparkColumns.vue +160 -0
  65. package/app/components/data/UiSparkline.vue +681 -0
  66. package/app/components/data/UiStat.vue +445 -0
  67. package/app/components/data/UiStats.vue +76 -0
  68. package/app/components/data/UiTable.vue +517 -0
  69. package/app/components/data/UiTableExport.vue +40 -0
  70. package/app/components/data/UiTableFrame.vue +149 -0
  71. package/app/components/data/UiTablePagination.vue +37 -0
  72. package/app/components/data/UiTableShell.vue +48 -0
  73. package/app/components/data/UiTableTd.vue +39 -0
  74. package/app/components/data/UiTableTh.vue +61 -0
  75. package/app/components/data/UiTopEntityStackChart.vue +393 -0
  76. package/app/components/data/cells/UiTableDash.vue +3 -0
  77. package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
  78. package/app/components/data/cells/UiTableMetricCell.vue +72 -0
  79. package/app/components/data/cells/UiTablePathCell.vue +76 -0
  80. package/app/components/data/cells/UiTableScoreTile.vue +49 -0
  81. package/app/components/data/cells/UiTableTrendCell.vue +41 -0
  82. package/app/components/element/UiAtmosphere.vue +64 -0
  83. package/app/components/element/UiAuthHeading.vue +24 -0
  84. package/app/components/element/UiAuthProviders.vue +46 -0
  85. package/app/components/element/UiBrandIcon.vue +39 -0
  86. package/app/components/element/UiButton.vue +549 -0
  87. package/app/components/element/UiCelebration.vue +65 -0
  88. package/app/components/element/UiChannelChips.vue +62 -0
  89. package/app/components/element/UiChatQuickLink.vue +319 -0
  90. package/app/components/element/UiChip.vue +101 -0
  91. package/app/components/element/UiConfirmDialog.vue +84 -0
  92. package/app/components/element/UiCopyButton.vue +90 -0
  93. package/app/components/element/UiDitherAvatar.vue +177 -0
  94. package/app/components/element/UiDotLabel.vue +19 -0
  95. package/app/components/element/UiEjectMenu.vue +181 -0
  96. package/app/components/element/UiEjectOverlay.vue +29 -0
  97. package/app/components/element/UiFavicon.vue +235 -0
  98. package/app/components/element/UiFaviconStack.vue +84 -0
  99. package/app/components/element/UiFilterMenu.vue +324 -0
  100. package/app/components/element/UiFocusedSurface.vue +129 -0
  101. package/app/components/element/UiHealthDot.vue +32 -0
  102. package/app/components/element/UiHelpLabel.vue +28 -0
  103. package/app/components/element/UiIcon.vue +22 -0
  104. package/app/components/element/UiInput.vue +35 -0
  105. package/app/components/element/UiLogoPlausible.vue +12 -0
  106. package/app/components/element/UiLogoPro.vue +25 -0
  107. package/app/components/element/UiLogoUmami.vue +12 -0
  108. package/app/components/element/UiNavIcon.vue +107 -0
  109. package/app/components/element/UiNavList.vue +156 -0
  110. package/app/components/element/UiOnboardingAnswers.vue +127 -0
  111. package/app/components/element/UiOverflowCount.vue +31 -0
  112. package/app/components/element/UiProBadge.vue +19 -0
  113. package/app/components/element/UiProgressCircle.vue +79 -0
  114. package/app/components/element/UiProgressPercent.vue +35 -0
  115. package/app/components/element/UiRelativeTime.vue +58 -0
  116. package/app/components/element/UiScrollFade.vue +16 -0
  117. package/app/components/element/UiSelect.vue +68 -0
  118. package/app/components/element/UiSeverityDot.vue +27 -0
  119. package/app/components/element/UiSiteFavicon.vue +24 -0
  120. package/app/components/element/UiSkeleton.vue +148 -0
  121. package/app/components/element/UiSlotText.vue +65 -0
  122. package/app/components/element/UiSourceLogos.vue +113 -0
  123. package/app/components/element/UiSourceRail.vue +71 -0
  124. package/app/components/element/UiStaticTerminal.vue +107 -0
  125. package/app/components/element/UiStatusBadge.vue +62 -0
  126. package/app/components/element/UiSyncDot.vue +46 -0
  127. package/app/components/element/UiTabs.vue +245 -0
  128. package/app/components/element/UiTogglePill.vue +86 -0
  129. package/app/components/element/UiTooltip.vue +390 -0
  130. package/app/components/element/UiTooltipGrid.vue +457 -0
  131. package/app/components/element/UiTrend.vue +184 -0
  132. package/app/components/element/UiTrialBenefits.vue +71 -0
  133. package/app/components/element/UiUrlLabel.vue +75 -0
  134. package/app/components/element/UiValenceDot.vue +37 -0
  135. package/app/components/element/UiWizardModal.vue +284 -0
  136. package/app/components/element/UiWizardNav.vue +59 -0
  137. package/app/components/element/UiWizardProgress.vue +78 -0
  138. package/app/components/element/UiWizardShell.vue +43 -0
  139. package/app/components/element/eject-menu.ts +359 -0
  140. package/app/components/element/eject-tracker.ts +34 -0
  141. package/app/components/element/onboarding-answers.ts +93 -0
  142. package/app/components/element/onboarding-intents.ts +19 -0
  143. package/app/composables/dataVizColors.ts +350 -0
  144. package/app/composables/formatting.ts +252 -0
  145. package/app/composables/semanticColors.ts +80 -0
  146. package/app/composables/useActivityOverlay.ts +55 -0
  147. package/app/composables/useAppShellNav.ts +21 -0
  148. package/app/composables/useChartBrush.ts +221 -0
  149. package/app/composables/useChartHover.ts +153 -0
  150. package/app/composables/useChartTickPlan.ts +186 -0
  151. package/app/composables/useCloseOnNavigation.ts +7 -0
  152. package/app/composables/useConfirm.ts +38 -0
  153. package/app/composables/useEdgeFade.ts +37 -0
  154. package/app/composables/useEject.ts +69 -0
  155. package/app/composables/useEntranceMotion.ts +22 -0
  156. package/app/composables/useFaviconBacking.ts +164 -0
  157. package/app/composables/useGridCols.ts +39 -0
  158. package/app/composables/useIssueOverlay.ts +43 -0
  159. package/app/composables/useOnboardingService.ts +106 -0
  160. package/app/composables/useOpenedOnce.ts +19 -0
  161. package/app/composables/useScrollSpy.ts +178 -0
  162. package/app/composables/useSquircleFallback.ts +21 -0
  163. package/app/composables/useWizardRoute.ts +123 -0
  164. package/app/plugins/favicon.ts +15 -0
  165. package/app/plugins/lazy-motion.ts +22 -0
  166. package/app/plugins/selection-highlight.client.ts +176 -0
  167. package/app/shared/cli.ts +10 -0
  168. package/app/shared/error-state.ts +150 -0
  169. package/app/shared/icons.ts +1 -0
  170. package/app/shared/motion.ts +1 -0
  171. package/app/shared/nav.ts +38 -0
  172. package/app/shared/navigation.ts +5 -0
  173. package/app/shared/static-terminal.ts +14 -0
  174. package/app/shared/table-export.ts +36 -0
  175. package/app/shared/table.ts +105 -0
  176. package/app/shared/ui-icons.ts +1 -0
  177. package/app/shared/view-state.ts +85 -0
  178. package/app/shared/widget-failure.ts +126 -0
  179. package/app/utils/app-favicon.ts +64 -0
  180. package/app/utils/chartAnnotations.ts +326 -0
  181. package/app/utils/countries.ts +337 -0
  182. package/app/utils/export-download.ts +100 -0
  183. package/app/utils/favicon-fallback.ts +12 -0
  184. package/app/utils/icon-color.ts +105 -0
  185. package/app/utils/number.ts +74 -0
  186. package/app/utils/render-link.ts +14 -0
  187. package/app/utils/slot-text.ts +18 -0
  188. package/app/utils/squircle.ts +154 -0
  189. package/app/utils/topEntityStack.ts +197 -0
  190. package/app/utils/vue.ts +57 -0
  191. package/build/design-system.ts +69 -0
  192. package/build/external-links.ts +38 -0
  193. package/build/fonts.ts +39 -0
  194. package/build/icon-server-bundle.ts +276 -0
  195. package/build/runtime/icon-server-bundle.ts +29 -0
  196. package/css/global.css +1896 -0
  197. package/nuxt.config.ts +143 -0
  198. package/package.json +93 -3
  199. package/public/fonts/README.md +33 -0
  200. package/public/fonts/fira-code-OFL.txt +93 -0
  201. package/public/fonts/fira-code-var-latin.woff2 +0 -0
  202. package/public/fonts/hubot-sans-OFL.txt +93 -0
  203. package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
  204. package/server/api/favicon.get.ts +122 -0
  205. package/server/utils/favicon-source.ts +51 -0
  206. package/shared/contracts/feedback.ts +42 -0
  207. package/shared/contracts/index.ts +1 -0
  208. package/shared/delivery-channels.ts +53 -0
  209. package/shared/icons/index.ts +143 -0
  210. package/shared/icons/registry.ts +347 -0
  211. package/shared/icons/ui-slots.ts +58 -0
  212. package/shared/markdown.ts +44 -0
  213. package/shared/motion.ts +120 -0
  214. package/shared/rangi.ts +60 -0
  215. package/shared/rich-text.ts +34 -0
  216. package/shared/ui-icons.ts +6 -0
  217. package/shared/utils/urls.ts +64 -0
  218. package/types/index.ts +1 -0
  219. package/types/ui.ts +339 -0
@@ -0,0 +1,57 @@
1
+ import type { VNode } from 'vue'
2
+ import { getCurrentInstance, toValue } from 'vue'
3
+
4
+ /**
5
+ * Helper function to check if the current setup-script component has a named listener
6
+ *
7
+ * @param name
8
+ */
9
+ export function hasListener(name: string): boolean {
10
+ const instance = getCurrentInstance()
11
+ const handler = `on${name.slice(0, 1).toUpperCase()}${name.slice(1)}`
12
+ return !!instance?.vnode.props && (handler in instance.vnode.props)
13
+ }
14
+
15
+ type Slot = (() => VNode[]) | undefined
16
+
17
+ /**
18
+ * Helper function to render a slot's text content
19
+ *
20
+ * @param slot
21
+ */
22
+ export function getSlotTextContent(slot: Slot): string {
23
+ if (!slot) {
24
+ return ''
25
+ }
26
+ return slot().map((node: VNode): string => {
27
+ if (typeof node.children === 'string') {
28
+ return node.children
29
+ }
30
+ else if (Array.isArray(node.children)) {
31
+ return getSlotTextContent(() => node.children as VNode[])
32
+ }
33
+ return ''
34
+ }).join(' ').trim()
35
+ }
36
+
37
+ export function deepUnref<T>(obj: T): T {
38
+ const value = toValue(obj)
39
+
40
+ if (!value || typeof value !== 'object') {
41
+ return value
42
+ }
43
+
44
+ if (Array.isArray(value)) {
45
+ return value.map(deepUnref) as T
46
+ }
47
+
48
+ const result: Record<string, unknown> = {}
49
+ for (const key in value) {
50
+ // Skip Vue's internal ref properties
51
+ if (key.startsWith('__v_') || key === '_rawValue' || key === '_value') {
52
+ continue
53
+ }
54
+ result[key] = deepUnref(value[key])
55
+ }
56
+ return result as T
57
+ }
@@ -0,0 +1,69 @@
1
+ import type { ModuleOptions as IconModuleOptions } from '@nuxt/icon'
2
+ import type { NuxtOptions } from 'nuxt/schema'
3
+ import type { IconSet } from '../shared/icons'
4
+ import { addTemplate, defineNuxtModule } from 'nuxt/kit'
5
+ import { iconAliasMap, iconBundleList, parseIconSet } from '../shared/icons'
6
+ import { externalLinkMarkerCss } from './external-links'
7
+
8
+ export interface DesignSystemOptions {
9
+ /**
10
+ * The icon set every semantic icon role renders from. Defaults to hugeicons.
11
+ * Install the matching `@iconify-json/<set>` collection in the app.
12
+ */
13
+ iconSet?: IconSet
14
+ /**
15
+ * Load Nuxt SEO's Hubot Sans and Fira Code faces as global families, so
16
+ * every page preloads them. Defaults to false: a site sets its own families
17
+ * through `fonts.families` and the `--font-*` tokens. The layer's
18
+ * `modules:before` hook reads it, because @nuxt/fonts reads its families
19
+ * when it installs, which can come before this module's setup.
20
+ */
21
+ fonts?: boolean
22
+ /**
23
+ * Hosts that count as this site. An absolute prose link to one of them gets
24
+ * no external-link arrow. Defaults to none, so every absolute http(s) link
25
+ * is marked. Matches a substring of the href after `//`, so list `www.` too.
26
+ */
27
+ internalLinkHosts?: string[]
28
+ }
29
+
30
+ /**
31
+ * Build-time brand choices for an app that extends the design system. Set them
32
+ * under `designSystem` in the app's Nuxt config.
33
+ *
34
+ * Every semantic icon role, and its synonyms, becomes a global @nuxt/icon alias
35
+ * for the chosen set. Role names then resolve everywhere: raw Nuxt UI components
36
+ * (`<UButton icon="next">`), data-array `icon:` fields, `<Icon name="next">`,
37
+ * content, app.config slots, and UiIcon/UiButton, which pass the name straight
38
+ * through. Resolution happens here, so the role registry never ships to the
39
+ * client runtime. The client bundle holds only the chosen set's role icons, and
40
+ * the server icon bundle (`icon-server-bundle.ts`) reads that list, so both
41
+ * follow the choice.
42
+ *
43
+ * Alias values MUST be `prefix:name`, not `i-...`: @nuxt/icon re-prefixes an
44
+ * alias target with `i-`, so an `i-` value becomes a broken `i-i-...`.
45
+ * `iconAliasMap` handles that and the synonyms.
46
+ *
47
+ * The layer lists this module before `@nuxt/ui`, which installs @nuxt/icon, so
48
+ * the options are in place before @nuxt/icon reads them.
49
+ */
50
+ export default defineNuxtModule<DesignSystemOptions>({
51
+ meta: { name: '@nuxtseo/design-system', configKey: 'designSystem' },
52
+ setup(options, nuxt) {
53
+ const iconSet = parseIconSet(options.iconSet)
54
+ // @nuxt/icon adds no `icon` key to the NuxtOptions type.
55
+ const opts = nuxt.options as NuxtOptions & { icon?: IconModuleOptions }
56
+ const icon = opts.icon ??= {}
57
+ // An alias the app declares itself wins over a role of the same name.
58
+ icon.aliases = { ...iconAliasMap(iconSet), ...icon.aliases }
59
+ icon.clientBundle ??= {}
60
+ icon.clientBundle.icons = [...new Set([...iconBundleList(iconSet), ...(icon.clientBundle.icons ?? [])])]
61
+
62
+ const externalLinks = addTemplate({
63
+ filename: 'design-system/external-links.css',
64
+ getContents: () => externalLinkMarkerCss(options.internalLinkHosts ?? []),
65
+ write: true,
66
+ })
67
+ nuxt.options.css.push(externalLinks.dst)
68
+ },
69
+ })
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The external-link arrow on outbound prose links. Only an absolute http(s)
3
+ * link that leaves the site gets it; a relative link never matches, and a
4
+ * link around an image is excluded because the arrow would hang off the
5
+ * corner of the figure.
6
+ *
7
+ * Which hosts count as this site is a brand choice, so the rules are built
8
+ * from `designSystem.internalLinkHosts`. A CSS custom property cannot feed an
9
+ * attribute selector, so the hosts have to reach the selector at build time.
10
+ *
11
+ * The marker is a background on a pseudo-element whose only content is a
12
+ * no-break space: the NBSP glues the arrow to the last word, so it can never
13
+ * orphan onto a line of its own, and there is no text for a screen reader to
14
+ * announce. The mask resolves `currentColor` against the image's own initial
15
+ * colour, which is opaque black, so the arrow always paints in the link's
16
+ * colour and the CSS holds no colour of its own.
17
+ */
18
+ export function externalLinkMarkerCss(internalHosts: readonly string[]): string {
19
+ const own = internalHosts
20
+ .map(host => `:not([href*="//${host.replace(/["\\]/g, '\\$&')}"])`)
21
+ .join('')
22
+ const link = `a.prose-link[href^="http"]${own}:not(:has(img))`
23
+ return `${link}::after {
24
+ content: '\\00a0';
25
+ padding-inline-end: 0.72em;
26
+ background-color: currentcolor;
27
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 7h10v10'/%3E%3Cpath d='M7 17 17 7'/%3E%3C/svg%3E");
28
+ mask-repeat: no-repeat;
29
+ mask-position: right center;
30
+ mask-size: 0.72em 0.72em;
31
+ opacity: 0.65;
32
+ vertical-align: baseline;
33
+ }
34
+ ${link}:hover::after {
35
+ opacity: 1;
36
+ }
37
+ `
38
+ }
package/build/fonts.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { FontFamilyManualOverride } from '@nuxt/fonts'
2
+
3
+ /**
4
+ * Nuxt SEO's own faces, self-hosted from this layer's `public/fonts/`. Builds
5
+ * never download them from a provider, so an app build does not depend on
6
+ * Google Fonts availability. Hubot Sans keeps its variable weight and width
7
+ * axes for the design tokens.
8
+ *
9
+ * A site loads them only when it sets `designSystem: { fonts: true }`. A
10
+ * global family is preloaded on every page, so a site with its own type stack
11
+ * would otherwise pay for two faces it never renders.
12
+ */
13
+ export const NUXT_SEO_FONT_FAMILIES: readonly FontFamilyManualOverride[] = [
14
+ {
15
+ name: 'Hubot Sans',
16
+ src: '/fonts/hubot-sans-var-latin.woff2',
17
+ weight: '200 900',
18
+ stretch: '75% 125%',
19
+ style: 'normal',
20
+ global: true,
21
+ },
22
+ {
23
+ name: 'Fira Code',
24
+ src: '/fonts/fira-code-var-latin.woff2',
25
+ weight: '400 500',
26
+ style: 'normal',
27
+ global: true,
28
+ },
29
+ ]
30
+
31
+ /**
32
+ * The font families @nuxt/fonts should register. With `enabled`, Nuxt SEO's
33
+ * faces come first and the app's own families follow; without it, the app's
34
+ * families pass through unchanged.
35
+ */
36
+ export function withNuxtSeoFonts<T>(families: readonly T[] | undefined, enabled: boolean | undefined): Array<T | FontFamilyManualOverride> {
37
+ const own = families ?? []
38
+ return enabled === true ? [...NUXT_SEO_FONT_FAMILIES, ...own] : [...own]
39
+ }
@@ -0,0 +1,276 @@
1
+ import type { ModuleOptions as IconModuleOptions } from '@nuxt/icon'
2
+ import type { Nuxt, NuxtOptions } from 'nuxt/schema'
3
+ import { existsSync, realpathSync } from 'node:fs'
4
+ import { glob, readFile } from 'node:fs/promises'
5
+ import { dirname, isAbsolute, join, relative } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { collectionNames, resolveBundleIcons } from '@nuxt/icon/utils'
8
+ import { addPlugin, addTemplate, addTypeTemplate, defineNuxtModule, useLogger } from 'nuxt/kit'
9
+ import { countryCodeToFlagIcon, countryData } from '../app/utils/countries'
10
+
11
+ // Server icon bundle: icon data for SSR only.
12
+ //
13
+ // The client bundle holds the active role set and a few brand marks. A raw
14
+ // `i-*` id outside it made @nuxt/icon fetch the icon from api.iconify.design
15
+ // during SSR, and the response waited for that fetch (up to its 1500 ms
16
+ // timeout) on every isolate that had not loaded that icon set yet.
17
+ //
18
+ // This module collects every raw icon id in the app's source, in the listed
19
+ // dependency code, and the ids the app builds at runtime. A server-only plugin
20
+ // registers their data. The browser bundle does not change: SSR writes each
21
+ // icon's CSS into the page. The plugin also removes the Iconify API from the
22
+ // server, so an id outside both bundles renders empty without a request. The
23
+ // browser then loads it.
24
+ //
25
+ // A production build fails when a scanned id has no icon data, and when the
26
+ // final server output names an id that neither bundle holds.
27
+
28
+ /** A raw icon id in `prefix:name` form, and the files that name it. */
29
+ export type IconSources = Map<string, string[]>
30
+
31
+ export type ServerIconCollection = Awaited<ReturnType<typeof resolveBundleIcons>>['collections'][number]
32
+
33
+ export interface ServerIconBundle {
34
+ collections: ServerIconCollection[]
35
+ count: number
36
+ sizeKb: number
37
+ /** Ids with no icon data: a typo, or a collection that is not installed. */
38
+ unresolved: Array<{ icon: string, files: string[] }>
39
+ /** `packages` entries with no installed directory. */
40
+ missingPackages: string[]
41
+ }
42
+
43
+ export interface BuildServerIconBundleInput {
44
+ /** Directories to scan for raw icon ids. */
45
+ roots: string[]
46
+ /**
47
+ * Dependency directories to scan, as `<package>/<path>`, for example
48
+ * `@gscdump/sdk/dist`. List a dependency whose code names raw ids that the
49
+ * app renders.
50
+ */
51
+ packages: string[]
52
+ /** Ids the app builds at runtime, which a source scan cannot see. */
53
+ runtimeIcons: Iterable<string>
54
+ /** Ids the client bundle already registers on the server. */
55
+ clientIcons: Iterable<string>
56
+ /** Directories that resolve the installed packages and `@iconify-json/*` collections. */
57
+ resolvePaths: string[]
58
+ }
59
+
60
+ const SOURCE_GLOB = '**/*.{vue,ts,tsx,js,mjs,md,yml,yaml}'
61
+ // Type unions, tests and tooling name icons that no build ships. Server code
62
+ // stays in: Nitro bundles it into the server output that the output check reads.
63
+ const SKIPPED_SEGMENT = /\/(?:node_modules|\.nuxt[^/]*|\.output|\.data|\.wrangler|dist|types|tests?|scripts|migrations[^/]*)(?:\/|$)/
64
+ const SKIPPED_FILE = /\.(?:d|test|spec)\.ts$/
65
+
66
+ const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67
+ // Longest prefix first, like @nuxt/icon's resolver, so `i-simple-icons-x` is
68
+ // `simple-icons:x` and not `simple:icons-x`.
69
+ const PREFIX = `(${[...collectionNames].sort((a, b) => b.length - a.length).map(escapeRegExp).join('|')})`
70
+ const NAME = '([a-z0-9]+(?:-[a-z0-9]+)*)'
71
+ // `i-lucide-x`, `i-lucide:x`, or a quoted `'lucide:x'`. A template prefix
72
+ // such as `i-vscode-icons-file-type-${ext}` is not an id.
73
+ const SOURCE_ICON = new RegExp(`\\bi-${PREFIX}[-:]${NAME}\\b(?![-a-z0-9]*\\$\\{)|(?<=['"\`])${PREFIX}:${NAME}(?=['"\`])`, 'g')
74
+ const RAW_ICON = new RegExp(`^(?:i-)?${PREFIX}[-:]${NAME}$`)
75
+
76
+ /** `i-circle-flags-de` or `circle-flags:de` to `circle-flags:de`. */
77
+ function toIconId(icon: string): string {
78
+ const match = RAW_ICON.exec(icon)
79
+ return match ? `${match[1]}:${match[2]}` : icon
80
+ }
81
+
82
+ /** Raw icon ids that `code` names, as `prefix:name`. */
83
+ function iconIdsIn(code: string): Set<string> {
84
+ const icons = new Set<string>()
85
+ for (const match of code.matchAll(SOURCE_ICON))
86
+ icons.add(match[1] ? `${match[1]}:${match[2]}` : `${match[3]}:${match[4]}`)
87
+ return icons
88
+ }
89
+
90
+ export async function scanIconSources(roots: string[]): Promise<IconSources> {
91
+ const sources: IconSources = new Map()
92
+ const seen = new Set<string>()
93
+ for (const root of roots) {
94
+ for await (const file of glob(SOURCE_GLOB, { cwd: root, exclude: path => SKIPPED_SEGMENT.test(`/${path}`) })) {
95
+ const path = `${root}/${file}`
96
+ if (seen.has(path) || SKIPPED_FILE.test(file))
97
+ continue
98
+ seen.add(path)
99
+ for (const icon of iconIdsIn(await readFile(path, 'utf8')))
100
+ sources.set(icon, [...sources.get(icon) ?? [], path])
101
+ }
102
+ }
103
+ return sources
104
+ }
105
+
106
+ /** `@gscdump/sdk/dist` to the `dist` directory of the installed `@gscdump/sdk`. */
107
+ function resolvePackageDir(entry: string, resolvePaths: string[]): string | undefined {
108
+ const parts = entry.split('/')
109
+ const nameParts = entry.startsWith('@') ? 2 : 1
110
+ const name = parts.slice(0, nameParts).join('/')
111
+ for (const base of resolvePaths) {
112
+ for (let dir = base; ; dir = dirname(dir)) {
113
+ const packageDir = join(dir, 'node_modules', name)
114
+ if (existsSync(join(packageDir, 'package.json'))) {
115
+ const scanned = join(realpathSync(packageDir), ...parts.slice(nameParts))
116
+ return existsSync(scanned) ? scanned : undefined
117
+ }
118
+ if (dirname(dir) === dir)
119
+ break
120
+ }
121
+ }
122
+ return undefined
123
+ }
124
+
125
+ /** Flag ids that `countryCodeToFlagIcon` builds at runtime for country tables. */
126
+ export function countryFlagIcons(): string[] {
127
+ const icons = Object.keys(countryData).map(code => countryCodeToFlagIcon(code))
128
+ return [...new Set(icons.filter(icon => icon.startsWith('i-')).map(toIconId))]
129
+ }
130
+
131
+ export async function buildServerIconBundle(input: BuildServerIconBundleInput): Promise<ServerIconBundle> {
132
+ const packageDirs = input.packages.map(entry => [entry, resolvePackageDir(entry, input.resolvePaths)] as const)
133
+ const sources = await scanIconSources([...input.roots, ...packageDirs.flatMap(([, dir]) => dir ? [dir] : [])])
134
+ const clientIcons = new Set(Array.from(input.clientIcons, toIconId))
135
+ const icons = [...new Set([...sources.keys(), ...Array.from(input.runtimeIcons, toIconId)])]
136
+ .filter(icon => !clientIcons.has(icon))
137
+ const { collections, count, failed } = await resolveBundleIcons({ icons, resolvePaths: input.resolvePaths })
138
+ return {
139
+ collections,
140
+ count,
141
+ sizeKb: Buffer.byteLength(JSON.stringify(collections)) / 1024,
142
+ unresolved: failed.sort().map(icon => ({ icon, files: sources.get(icon) ?? [] })),
143
+ missingPackages: packageDirs.flatMap(([entry, dir]) => dir ? [] : [entry]),
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Ids that the server output names but neither bundle holds. SSR renders them
149
+ * empty. Template prefixes such as `i-vscode-icons-file-type-${ext}` are left
150
+ * out, because no bundle can hold every id they build.
151
+ */
152
+ export function unbundledServerIcons(
153
+ chunks: Iterable<{ fileName: string, code: string }>,
154
+ bundle: Pick<ServerIconBundle, 'collections'>,
155
+ clientIcons: Iterable<string>,
156
+ ): Array<{ icon: string, files: string[] }> {
157
+ const bundled = new Set(Array.from(clientIcons, toIconId))
158
+ for (const collection of bundle.collections) {
159
+ for (const name of [...Object.keys(collection.icons), ...Object.keys(collection.aliases ?? {})])
160
+ bundled.add(`${collection.prefix}:${name}`)
161
+ }
162
+ const missing = new Map<string, string[]>()
163
+ for (const { fileName, code } of chunks) {
164
+ for (const icon of iconIdsIn(code)) {
165
+ if (!bundled.has(icon))
166
+ missing.set(icon, [...missing.get(icon) ?? [], fileName])
167
+ }
168
+ }
169
+ return [...missing].sort(([a], [b]) => a.localeCompare(b)).map(([icon, files]) => ({ icon, files }))
170
+ }
171
+
172
+ export function renderServerIconBundle(bundle: Pick<ServerIconBundle, 'collections'>): string {
173
+ return `export const collections = JSON.parse(${JSON.stringify(JSON.stringify(bundle.collections))})\n`
174
+ }
175
+
176
+ function scanRoots(nuxt: Nuxt): string[] {
177
+ const layers = nuxt.options._layers.map(layer => layer.config.rootDir)
178
+ // Local modules ship runtime code outside any layer, for example the Site nav.
179
+ const modules = nuxt.options._installedModules
180
+ .map(installed => installed.entryPath)
181
+ .filter((path): path is string => !!path && isAbsolute(path) && !path.includes('/node_modules/'))
182
+ .map(path => dirname(path))
183
+ return [...new Set([...layers, ...modules])]
184
+ }
185
+
186
+ // Build-blocking ceiling for the uncompressed icon data. Pro held 530 icons,
187
+ // 406 KB, on 2026-10-29. Country flags took 161 KB, and Nuxt UI's default and
188
+ // code-block icons took about 60 KB.
189
+ const SIZE_LIMIT_KB = 512
190
+
191
+ export interface IconServerBundleOptions {
192
+ /** Dependency directories to scan, as `<package>/<path>`. See {@link BuildServerIconBundleInput.packages}. */
193
+ packages: string[]
194
+ }
195
+
196
+ export function iconServerBundleModule(options: IconServerBundleOptions) {
197
+ return defineNuxtModule({
198
+ meta: { name: 'nuxtseo-icon-server-bundle' },
199
+ setup(_options, nuxt) {
200
+ const logger = useLogger('icon-server-bundle')
201
+ // @nuxt/icon adds no `icon` key to the NuxtOptions type.
202
+ const clientIcons = () => (nuxt.options as NuxtOptions & { icon?: IconModuleOptions }).icon?.clientBundle?.icons ?? []
203
+
204
+ // One scan per process. In dev, restart to bundle an id added after start.
205
+ let pending: Promise<ServerIconBundle> | undefined
206
+ const loadBundle = () => pending ??= buildServerIconBundle({
207
+ roots: scanRoots(nuxt),
208
+ packages: options.packages,
209
+ runtimeIcons: countryFlagIcons(),
210
+ clientIcons: clientIcons(),
211
+ resolvePaths: [...new Set([nuxt.options.rootDir, nuxt.options.workspaceDir, ...nuxt.options._layers.map(layer => layer.config.rootDir)])],
212
+ })
213
+ const report = (problem: string) => {
214
+ if (!nuxt.options.dev)
215
+ throw new Error(`[icon-server-bundle] ${problem}`)
216
+ logger.warn(problem)
217
+ }
218
+
219
+ // Check before the bundler runs. A throw inside a template is reported as
220
+ // a missing `#build` file instead of this message.
221
+ nuxt.hook('build:before', async () => {
222
+ if (nuxt.options._prepare)
223
+ return
224
+ const bundle = await loadBundle()
225
+ const problems: string[] = []
226
+ if (bundle.unresolved.length) {
227
+ const list = bundle.unresolved.map(({ icon, files }) => ` - ${icon} ${files.map(file => relative(nuxt.options.workspaceDir, file)).join(', ')}`)
228
+ problems.push(`These icon ids have no icon data. Fix the name, or install its @iconify-json collection:\n${list.join('\n')}`)
229
+ }
230
+ if (bundle.missingPackages.length)
231
+ problems.push(`These \`packages\` entries have no installed directory. Install the package, or remove the entry:\n${bundle.missingPackages.map(entry => ` - ${entry}`).join('\n')}`)
232
+ if (bundle.sizeKb > SIZE_LIMIT_KB)
233
+ problems.push(`The server icon bundle is ${bundle.sizeKb.toFixed(1)} KB, over its ${SIZE_LIMIT_KB} KB limit.`)
234
+ if (problems.length)
235
+ report(problems.join('\n'))
236
+ logger.info(`Server icon bundle: ${bundle.count} icons, ${bundle.sizeKb.toFixed(1)} KB uncompressed.`)
237
+ })
238
+
239
+ // The scan reads source files and the listed dependencies. This check
240
+ // reads what Nitro ships, so it also finds an id in a dependency that
241
+ // `packages` leaves out.
242
+ nuxt.hook('nitro:init', (nitro) => {
243
+ nitro.hooks.hook('rollup:before', (_nitro, rollupConfig) => {
244
+ const plugins = rollupConfig.plugins
245
+ rollupConfig.plugins = [...(Array.isArray(plugins) ? plugins : [plugins]), {
246
+ name: 'nuxtseo:icon-server-bundle-check',
247
+ async generateBundle(_options, output) {
248
+ const chunks = Object.values(output).flatMap(file => file.type === 'chunk' ? [file] : [])
249
+ const missing = unbundledServerIcons(chunks, await loadBundle(), clientIcons())
250
+ if (!missing.length)
251
+ return
252
+ const list = missing.map(({ icon, files }) => ` - ${icon} ${files.join(', ')}`)
253
+ report(`The server output names these icon ids, and neither icon bundle holds them. SSR renders them empty. If a dependency names an id, add its directory to the \`packages\` option:\n${list.join('\n')}`)
254
+ },
255
+ }]
256
+ })
257
+ })
258
+
259
+ addTemplate({
260
+ filename: 'icon-server-bundle.mjs',
261
+ getContents: async () => renderServerIconBundle(await loadBundle()),
262
+ })
263
+ addTypeTemplate({
264
+ filename: 'types/icon-server-bundle.d.ts',
265
+ getContents: () => `declare module '#build/icon-server-bundle.mjs' {
266
+ export const collections: import(${JSON.stringify(fileURLToPath(new URL('./icon-server-bundle', import.meta.url)))}).ServerIconCollection[]
267
+ }
268
+ `,
269
+ })
270
+ addPlugin({
271
+ src: fileURLToPath(new URL('./runtime/icon-server-bundle', import.meta.url)),
272
+ mode: 'server',
273
+ })
274
+ },
275
+ })
276
+ }
@@ -0,0 +1,29 @@
1
+ import type { IconifyAPIModule } from '@iconify/vue'
2
+ import { _api, addCollection } from '@iconify/vue'
3
+ import { defineNuxtPlugin } from 'nuxt/app'
4
+ import { collections } from '#build/icon-server-bundle.mjs'
5
+
6
+ // Answers every Iconify API query as "not found" without a request. On the
7
+ // server an icon outside the client and server bundles renders empty at once,
8
+ // and the browser loads it after hydration. The response never waits on
9
+ // api.iconify.design.
10
+ const offlineApi: IconifyAPIModule = {
11
+ prepare: (provider, prefix, icons) => [{ type: 'icons', provider, prefix, icons }],
12
+ send: (_host, _params, respond) => respond('abort', 404),
13
+ }
14
+
15
+ // Iconify keeps icons and API modules in module scope, so one setup serves
16
+ // every request in the isolate.
17
+ let installed = false
18
+
19
+ export default defineNuxtPlugin({
20
+ name: 'design-system:icon-server-bundle',
21
+ setup() {
22
+ if (installed)
23
+ return
24
+ installed = true
25
+ _api.setAPIModule('', offlineApi)
26
+ for (const collection of collections)
27
+ addCollection(collection)
28
+ },
29
+ })