@nuxtseo/design-system 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -2
- package/app/app.config.ts +596 -0
- package/app/component-attributes.d.ts +8 -0
- package/app/components/README.md +38 -0
- package/app/components/brand/UiBrandMark.vue +63 -0
- package/app/components/brand/UiModuleIcon.vue +45 -0
- package/app/components/brand/UiProLettering.vue +26 -0
- package/app/components/brand/UiRangeTerrain.vue +311 -0
- package/app/components/brand/range-glyphs.ts +18 -0
- package/app/components/container/BrowserTab.vue +145 -0
- package/app/components/container/IntegrationRow.vue +120 -0
- package/app/components/container/KnowDoRails.vue +40 -0
- package/app/components/container/RailPanel.vue +93 -0
- package/app/components/container/UiAlert.vue +120 -0
- package/app/components/container/UiAppShell.vue +165 -0
- package/app/components/container/UiCard.vue +204 -0
- package/app/components/container/UiDisclosure.vue +43 -0
- package/app/components/container/UiEmptyState.vue +102 -0
- package/app/components/container/UiErrorState.vue +61 -0
- package/app/components/container/UiLoadingState.vue +20 -0
- package/app/components/container/UiPageHeader.vue +95 -0
- package/app/components/container/UiPopover.vue +102 -0
- package/app/components/container/UiSampleDataOverlay.vue +47 -0
- package/app/components/container/UiSectionHeader.vue +83 -0
- package/app/components/container/UiSummaryStrip.vue +40 -0
- package/app/components/container/UiToast.vue +230 -0
- package/app/components/container/UiToaster.vue +271 -0
- package/app/components/container/UiWidgetState.vue +143 -0
- package/app/components/container/ui-nav-drawer.ts +48 -0
- package/app/components/data/ActionRail.vue +152 -0
- package/app/components/data/FacetStrip.vue +93 -0
- package/app/components/data/UiBarCell.vue +37 -0
- package/app/components/data/UiBarChart.vue +281 -0
- package/app/components/data/UiChartAnnotationControls.vue +74 -0
- package/app/components/data/UiChartAnnotations.vue +163 -0
- package/app/components/data/UiChartBrushOverlay.vue +96 -0
- package/app/components/data/UiChartDataReveal.vue +81 -0
- package/app/components/data/UiChartFrame.vue +123 -0
- package/app/components/data/UiChartTooltip.vue +81 -0
- package/app/components/data/UiCoverageNote.vue +90 -0
- package/app/components/data/UiCwvSparkline.vue +188 -0
- package/app/components/data/UiDataList.vue +276 -0
- package/app/components/data/UiDataTableSection.vue +232 -0
- package/app/components/data/UiDistributionBars.vue +164 -0
- package/app/components/data/UiDonutChart.vue +132 -0
- package/app/components/data/UiDoorwayColumn.vue +108 -0
- package/app/components/data/UiDoorwayGrid.vue +58 -0
- package/app/components/data/UiDoorwayRow.vue +83 -0
- package/app/components/data/UiEntitySummary.vue +261 -0
- package/app/components/data/UiFactsGrid.vue +107 -0
- package/app/components/data/UiImpactMeter.vue +33 -0
- package/app/components/data/UiIssueRow.vue +149 -0
- package/app/components/data/UiLineChart.vue +431 -0
- package/app/components/data/UiMetricBarPopover.vue +121 -0
- package/app/components/data/UiMetricLabel.vue +30 -0
- package/app/components/data/UiMetricStat.vue +73 -0
- package/app/components/data/UiMetricToggle.vue +156 -0
- package/app/components/data/UiMetricsRow.vue +64 -0
- package/app/components/data/UiPositionMetric.vue +53 -0
- package/app/components/data/UiRelatedFixes.vue +113 -0
- package/app/components/data/UiRichText.vue +325 -0
- package/app/components/data/UiScatterPlot.vue +144 -0
- package/app/components/data/UiSeverityMarker.vue +30 -0
- package/app/components/data/UiSparkColumns.vue +160 -0
- package/app/components/data/UiSparkline.vue +681 -0
- package/app/components/data/UiStat.vue +445 -0
- package/app/components/data/UiStats.vue +76 -0
- package/app/components/data/UiTable.vue +517 -0
- package/app/components/data/UiTableExport.vue +40 -0
- package/app/components/data/UiTableFrame.vue +149 -0
- package/app/components/data/UiTablePagination.vue +37 -0
- package/app/components/data/UiTableShell.vue +48 -0
- package/app/components/data/UiTableTd.vue +39 -0
- package/app/components/data/UiTableTh.vue +61 -0
- package/app/components/data/UiTopEntityStackChart.vue +393 -0
- package/app/components/data/cells/UiTableDash.vue +3 -0
- package/app/components/data/cells/UiTableHeaderCell.vue +72 -0
- package/app/components/data/cells/UiTableMetricCell.vue +72 -0
- package/app/components/data/cells/UiTablePathCell.vue +76 -0
- package/app/components/data/cells/UiTableScoreTile.vue +49 -0
- package/app/components/data/cells/UiTableTrendCell.vue +41 -0
- package/app/components/element/UiAtmosphere.vue +64 -0
- package/app/components/element/UiAuthHeading.vue +24 -0
- package/app/components/element/UiAuthProviders.vue +46 -0
- package/app/components/element/UiBrandIcon.vue +39 -0
- package/app/components/element/UiButton.vue +549 -0
- package/app/components/element/UiCelebration.vue +65 -0
- package/app/components/element/UiChannelChips.vue +62 -0
- package/app/components/element/UiChatQuickLink.vue +319 -0
- package/app/components/element/UiChip.vue +101 -0
- package/app/components/element/UiConfirmDialog.vue +84 -0
- package/app/components/element/UiCopyButton.vue +90 -0
- package/app/components/element/UiDitherAvatar.vue +177 -0
- package/app/components/element/UiDotLabel.vue +19 -0
- package/app/components/element/UiEjectMenu.vue +181 -0
- package/app/components/element/UiEjectOverlay.vue +29 -0
- package/app/components/element/UiFavicon.vue +235 -0
- package/app/components/element/UiFaviconStack.vue +84 -0
- package/app/components/element/UiFilterMenu.vue +324 -0
- package/app/components/element/UiFocusedSurface.vue +129 -0
- package/app/components/element/UiHealthDot.vue +32 -0
- package/app/components/element/UiHelpLabel.vue +28 -0
- package/app/components/element/UiIcon.vue +22 -0
- package/app/components/element/UiInput.vue +35 -0
- package/app/components/element/UiLogoPlausible.vue +12 -0
- package/app/components/element/UiLogoPro.vue +25 -0
- package/app/components/element/UiLogoUmami.vue +12 -0
- package/app/components/element/UiNavIcon.vue +107 -0
- package/app/components/element/UiNavList.vue +156 -0
- package/app/components/element/UiOnboardingAnswers.vue +125 -0
- package/app/components/element/UiOverflowCount.vue +31 -0
- package/app/components/element/UiProBadge.vue +19 -0
- package/app/components/element/UiProgressCircle.vue +79 -0
- package/app/components/element/UiProgressPercent.vue +35 -0
- package/app/components/element/UiRelativeTime.vue +58 -0
- package/app/components/element/UiScrollFade.vue +16 -0
- package/app/components/element/UiSelect.vue +55 -0
- package/app/components/element/UiSeverityDot.vue +27 -0
- package/app/components/element/UiSiteFavicon.vue +24 -0
- package/app/components/element/UiSkeleton.vue +148 -0
- package/app/components/element/UiSlotText.vue +65 -0
- package/app/components/element/UiSourceLogos.vue +113 -0
- package/app/components/element/UiSourceRail.vue +71 -0
- package/app/components/element/UiStaticTerminal.vue +107 -0
- package/app/components/element/UiStatusBadge.vue +62 -0
- package/app/components/element/UiSyncDot.vue +46 -0
- package/app/components/element/UiTabs.vue +245 -0
- package/app/components/element/UiTogglePill.vue +86 -0
- package/app/components/element/UiTooltip.vue +390 -0
- package/app/components/element/UiTooltipGrid.vue +457 -0
- package/app/components/element/UiTrend.vue +184 -0
- package/app/components/element/UiTrialBenefits.vue +71 -0
- package/app/components/element/UiUrlLabel.vue +75 -0
- package/app/components/element/UiValenceDot.vue +37 -0
- package/app/components/element/UiWizardModal.vue +284 -0
- package/app/components/element/UiWizardNav.vue +59 -0
- package/app/components/element/UiWizardProgress.vue +78 -0
- package/app/components/element/UiWizardShell.vue +43 -0
- package/app/components/element/eject-menu.ts +359 -0
- package/app/components/element/eject-tracker.ts +34 -0
- package/app/components/element/onboarding-answers.ts +68 -0
- package/app/components/element/onboarding-intents.ts +19 -0
- package/app/composables/dataVizColors.ts +350 -0
- package/app/composables/formatting.ts +252 -0
- package/app/composables/semanticColors.ts +80 -0
- package/app/composables/useActivityOverlay.ts +55 -0
- package/app/composables/useAppShellNav.ts +21 -0
- package/app/composables/useChartBrush.ts +221 -0
- package/app/composables/useChartHover.ts +153 -0
- package/app/composables/useChartTickPlan.ts +186 -0
- package/app/composables/useCloseOnNavigation.ts +7 -0
- package/app/composables/useConfirm.ts +38 -0
- package/app/composables/useEdgeFade.ts +37 -0
- package/app/composables/useEject.ts +69 -0
- package/app/composables/useEntranceMotion.ts +22 -0
- package/app/composables/useFaviconBacking.ts +164 -0
- package/app/composables/useGridCols.ts +39 -0
- package/app/composables/useIssueOverlay.ts +43 -0
- package/app/composables/useOnboardingService.ts +106 -0
- package/app/composables/useOpenedOnce.ts +19 -0
- package/app/composables/useScrollSpy.ts +178 -0
- package/app/composables/useSquircleFallback.ts +21 -0
- package/app/composables/useWizardRoute.ts +123 -0
- package/app/plugins/favicon.ts +29 -0
- package/app/plugins/lazy-motion.ts +22 -0
- package/app/plugins/selection-highlight.client.ts +176 -0
- package/app/shared/cli.ts +10 -0
- package/app/shared/error-state.ts +150 -0
- package/app/shared/icons.ts +1 -0
- package/app/shared/motion.ts +1 -0
- package/app/shared/nav.ts +38 -0
- package/app/shared/navigation.ts +5 -0
- package/app/shared/static-terminal.ts +14 -0
- package/app/shared/table-export.ts +36 -0
- package/app/shared/table.ts +105 -0
- package/app/shared/ui-icons.ts +1 -0
- package/app/shared/view-state.ts +85 -0
- package/app/shared/widget-failure.ts +126 -0
- package/app/utils/chartAnnotations.ts +326 -0
- package/app/utils/countries.ts +337 -0
- package/app/utils/export-download.ts +100 -0
- package/app/utils/favicon-fallback.ts +12 -0
- package/app/utils/icon-color.ts +105 -0
- package/app/utils/number.ts +74 -0
- package/app/utils/render-link.ts +14 -0
- package/app/utils/slot-text.ts +18 -0
- package/app/utils/squircle.ts +154 -0
- package/app/utils/topEntityStack.ts +197 -0
- package/app/utils/vue.ts +57 -0
- package/build/design-system.ts +47 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1892 -0
- package/nuxt.config.ts +132 -0
- package/package.json +86 -3
- package/public/brand-icon-dark.png +0 -0
- package/public/brand-icon-light.png +0 -0
- package/public/favicon-dark.png +0 -0
- package/public/favicon-light.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/fonts/README.md +31 -0
- package/public/fonts/fira-code-OFL.txt +93 -0
- package/public/fonts/fira-code-var-latin.woff2 +0 -0
- package/public/fonts/hubot-sans-OFL.txt +93 -0
- package/public/fonts/hubot-sans-var-latin.woff2 +0 -0
- package/public/icon-dark.png +0 -0
- package/public/icon.png +0 -0
- package/public/touch-icon-dark.png +0 -0
- package/public/touch-icon.png +0 -0
- package/server/api/favicon.get.ts +122 -0
- package/server/utils/favicon-source.ts +51 -0
- package/shared/contracts/feedback.ts +42 -0
- package/shared/contracts/index.ts +1 -0
- package/shared/delivery-channels.ts +53 -0
- package/shared/icons/index.ts +143 -0
- package/shared/icons/registry.ts +347 -0
- package/shared/icons/ui-slots.ts +58 -0
- package/shared/markdown.ts +44 -0
- package/shared/motion.ts +120 -0
- package/shared/rangi.ts +60 -0
- package/shared/rich-text.ts +34 -0
- package/shared/ui-icons.ts +6 -0
- package/shared/utils/urls.ts +64 -0
- package/types/index.ts +1 -0
- package/types/ui.ts +339 -0
package/app/utils/vue.ts
ADDED
|
@@ -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,47 @@
|
|
|
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 { defineNuxtModule } from 'nuxt/kit'
|
|
5
|
+
import { iconAliasMap, iconBundleList, parseIconSet } from '../shared/icons'
|
|
6
|
+
|
|
7
|
+
export interface DesignSystemOptions {
|
|
8
|
+
/**
|
|
9
|
+
* The icon set every semantic icon role renders from. Defaults to hugeicons.
|
|
10
|
+
* Install the matching `@iconify-json/<set>` collection in the app.
|
|
11
|
+
*/
|
|
12
|
+
iconSet?: IconSet
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build-time brand choices for an app that extends the design system. Set them
|
|
17
|
+
* under `designSystem` in the app's Nuxt config.
|
|
18
|
+
*
|
|
19
|
+
* Every semantic icon role, and its synonyms, becomes a global @nuxt/icon alias
|
|
20
|
+
* for the chosen set. Role names then resolve everywhere: raw Nuxt UI components
|
|
21
|
+
* (`<UButton icon="next">`), data-array `icon:` fields, `<Icon name="next">`,
|
|
22
|
+
* content, app.config slots, and UiIcon/UiButton, which pass the name straight
|
|
23
|
+
* through. Resolution happens here, so the role registry never ships to the
|
|
24
|
+
* client runtime. The client bundle holds only the chosen set's role icons, and
|
|
25
|
+
* the server icon bundle (`icon-server-bundle.ts`) reads that list, so both
|
|
26
|
+
* follow the choice.
|
|
27
|
+
*
|
|
28
|
+
* Alias values MUST be `prefix:name`, not `i-...`: @nuxt/icon re-prefixes an
|
|
29
|
+
* alias target with `i-`, so an `i-` value becomes a broken `i-i-...`.
|
|
30
|
+
* `iconAliasMap` handles that and the synonyms.
|
|
31
|
+
*
|
|
32
|
+
* The layer lists this module before `@nuxt/ui`, which installs @nuxt/icon, so
|
|
33
|
+
* the options are in place before @nuxt/icon reads them.
|
|
34
|
+
*/
|
|
35
|
+
export default defineNuxtModule<DesignSystemOptions>({
|
|
36
|
+
meta: { name: '@nuxtseo/design-system', configKey: 'designSystem' },
|
|
37
|
+
setup(options, nuxt) {
|
|
38
|
+
const iconSet = parseIconSet(options.iconSet)
|
|
39
|
+
// @nuxt/icon adds no `icon` key to the NuxtOptions type.
|
|
40
|
+
const opts = nuxt.options as NuxtOptions & { icon?: IconModuleOptions }
|
|
41
|
+
const icon = opts.icon ??= {}
|
|
42
|
+
// An alias the app declares itself wins over a role of the same name.
|
|
43
|
+
icon.aliases = { ...iconAliasMap(iconSet), ...icon.aliases }
|
|
44
|
+
icon.clientBundle ??= {}
|
|
45
|
+
icon.clientBundle.icons = [...new Set([...iconBundleList(iconSet), ...(icon.clientBundle.icons ?? [])])]
|
|
46
|
+
},
|
|
47
|
+
})
|
|
@@ -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
|
+
})
|