@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.
- package/README.md +181 -2
- package/app/app.config.ts +592 -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 +233 -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 +127 -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 +68 -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 +93 -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 +15 -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/app-favicon.ts +64 -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 +69 -0
- package/build/external-links.ts +38 -0
- package/build/fonts.ts +39 -0
- package/build/icon-server-bundle.ts +276 -0
- package/build/runtime/icon-server-bundle.ts +29 -0
- package/css/global.css +1896 -0
- package/nuxt.config.ts +143 -0
- package/package.json +93 -3
- package/public/fonts/README.md +33 -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/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
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { LocationQueryRaw, RouteLocationRaw } from 'vue-router'
|
|
2
|
+
import { CLI_INVOCATION } from '../../shared/cli'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Eject-to-code / eject-to-chat payload shapes shared by `<UiEjectMenu>` and any
|
|
6
|
+
* primitive that embeds it (e.g. `<UiCard chat>`). The URL contract for the chat
|
|
7
|
+
* deep-link is owned by `layers/chat/app/internal/schemas/chat-deep-link.ts`; keep
|
|
8
|
+
* the `entity` / `filter` / `seed` fields in sync with that schema.
|
|
9
|
+
*/
|
|
10
|
+
export interface EjectChat {
|
|
11
|
+
/**
|
|
12
|
+
* Navigation target. **Default (omit `to`): open the contextual chat
|
|
13
|
+
* slide-over in place** on the current route — <ProChatPanel> self-opens on
|
|
14
|
+
* the `?seed=&entity=&filter=` query everywhere except `/chat` routes. This is
|
|
15
|
+
* the rule for every data-surface eject (card / row / section): stay in
|
|
16
|
+
* context. Only set `to: '/pro/dashboard/chat'` (full-page chat) in the two
|
|
17
|
+
* cases where an in-place slide-over can't or shouldn't open: (1) the eject
|
|
18
|
+
* lives inside another open overlay/modal, where a slide-over would stack; (2)
|
|
19
|
+
* a global launcher (command palette) where chat IS the destination.
|
|
20
|
+
*/
|
|
21
|
+
to?: RouteLocationRaw
|
|
22
|
+
entity?: ChatEntityRef
|
|
23
|
+
filter?: Record<string, unknown>
|
|
24
|
+
seed?: string
|
|
25
|
+
// When true, the chat fires `sendMessage(seed)` automatically once hydrate
|
|
26
|
+
// resolves. Convention: eject seeds are complete, self-contained questions
|
|
27
|
+
// built from real card/row state, so they auto-send (`true`) — the user gets
|
|
28
|
+
// an answer on open, not a half-typed box. Leave unset only when the seed is a
|
|
29
|
+
// deliberate starting point the user is expected to edit before sending.
|
|
30
|
+
autoSend?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EjectLink {
|
|
34
|
+
to: RouteLocationRaw
|
|
35
|
+
label?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EjectMcp {
|
|
39
|
+
tool: string
|
|
40
|
+
args?: Record<string, unknown>
|
|
41
|
+
/**
|
|
42
|
+
* The Site this payload is about, as the `nuxtseo` CLI's `--site` expects it:
|
|
43
|
+
* the Site public id (`s_…`) that `nuxtseo sites list` prints. The canonical
|
|
44
|
+
* site id resolves too, so either is safe; a URL or a bare domain is NOT the
|
|
45
|
+
* same identifier and must never be passed here.
|
|
46
|
+
*
|
|
47
|
+
* `args.siteUrl` cannot stand in for it. That field is the MCP wire argument,
|
|
48
|
+
* a URL, and the CLI has no flag that takes one.
|
|
49
|
+
*
|
|
50
|
+
* Omit it only for an account-scoped tool. A site-scoped tool without it
|
|
51
|
+
* yields no CLI item at all (`serializeCliCommand` returns null) rather than a
|
|
52
|
+
* command that exits 5 `site_ambiguous` for anyone with two or more Sites.
|
|
53
|
+
* Pro surfaces build this payload as `ProMcpToolCall`, whose site-scoped
|
|
54
|
+
* variant makes the field REQUIRED, so a call site cannot forget it.
|
|
55
|
+
*/
|
|
56
|
+
site?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Serialise an MCP eject payload in the MCP tools/call wire shape
|
|
61
|
+
* (`arguments`, not our internal `args`), so anything an agent or MCP inspector
|
|
62
|
+
* accepts can take it verbatim. Single owner of the copied string; UiEjectMenu
|
|
63
|
+
* and any bespoke menu must serialise through this.
|
|
64
|
+
*
|
|
65
|
+
* The payload is always the structured shape, never a pre-serialised string: a
|
|
66
|
+
* string carries no `tool` and no `site`, so the menu could not derive the CLI
|
|
67
|
+
* command that leads the eject set (ADR-0124). `ProActionRow` passed one for a
|
|
68
|
+
* while and silently lost its CLI item.
|
|
69
|
+
*/
|
|
70
|
+
export function serializeMcpCall(mcp: EjectMcp): string {
|
|
71
|
+
return JSON.stringify({ tool: mcp.tool, arguments: mcp.args ?? {} }, null, 2)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* MCP tool → shipped `nuxtseo` CLI command. ADR-0124 makes the CLI the primary
|
|
76
|
+
* access surface, so the eject menu leads with a CLI command wherever one
|
|
77
|
+
* exists.
|
|
78
|
+
*
|
|
79
|
+
* Only tools whose command SHIPS today appear here. An unmapped tool returns
|
|
80
|
+
* null and the menu simply omits the item — a copied command that does not
|
|
81
|
+
* exist is the same broken promise the cURL ban used to guard against.
|
|
82
|
+
*
|
|
83
|
+
* `crux_vitals` (and its deprecated `performance` alias) is deliberately absent:
|
|
84
|
+
* it reads CrUX field p75, while the `performance` CLI command reads the
|
|
85
|
+
* Lighthouse lab overview. A mapping would copy a command that answers a
|
|
86
|
+
* different question.
|
|
87
|
+
*/
|
|
88
|
+
type CliArgs = Record<string, unknown>
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The raw pieces of one copied command, NOT a rendered string.
|
|
92
|
+
*
|
|
93
|
+
* An argument value may itself contain ` --` (a keyword like
|
|
94
|
+
* `best tools -- 2026 guide`, already legal under `shellArg` quoting), so any
|
|
95
|
+
* insertion point derived by splitting the rendered command lands INSIDE a
|
|
96
|
+
* quoted value and mangles it. Composing tokens and ordered flags structurally
|
|
97
|
+
* keeps every insertion point structural: `--site` joins between the root
|
|
98
|
+
* tokens and the command's own flags, whatever the values contain.
|
|
99
|
+
*/
|
|
100
|
+
interface CliFlag {
|
|
101
|
+
name: string
|
|
102
|
+
/** Omitted for a valueless flag; raw (unquoted) when present. */
|
|
103
|
+
value?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface CliCommand {
|
|
107
|
+
/** Root positional tokens before any flag; raw (unquoted). */
|
|
108
|
+
tokens: string[]
|
|
109
|
+
/** Command-specific flags, ordered as the CLI documents them. */
|
|
110
|
+
flags: CliFlag[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cliCommand(tokens: string[], flags: Array<CliFlag | null>): CliCommand {
|
|
114
|
+
return { tokens, flags: flags.filter((entry): entry is CliFlag => entry !== null) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Quote only when the shell would otherwise split or interpret the value. A
|
|
118
|
+
// bare `actions show t_9` is what the user would type; `actions show "t_9"`
|
|
119
|
+
// reads like machine output and invites a copy that keeps the quotes.
|
|
120
|
+
function shellArg(value: string): string {
|
|
121
|
+
return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Renders one flag, quoting its value at emit time. */
|
|
125
|
+
function renderFlag(entry: CliFlag): string {
|
|
126
|
+
return entry.value === undefined ? `--${entry.name}` : `--${entry.name} ${shellArg(entry.value)}`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function optionalFlag(args: CliArgs, key: string, name: string): CliFlag | null {
|
|
130
|
+
const value = args[key]
|
|
131
|
+
if (value === undefined || value === null || value === '')
|
|
132
|
+
return null
|
|
133
|
+
return { name, value: String(value) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Every command below except `usage` and `sites list` resolves a Site before it
|
|
138
|
+
* runs (`apiAndSite` in `packages/cli/src/commands.ts`). Without `--site`, a
|
|
139
|
+
* non-interactive run of one of those exits 5 `site_ambiguous` the moment the
|
|
140
|
+
* token can see two Sites, so `scope: 'site'` is what makes the menu omit the
|
|
141
|
+
* item instead of copying a command that fails.
|
|
142
|
+
*/
|
|
143
|
+
interface CliCommandSpec {
|
|
144
|
+
scope: 'site' | 'account'
|
|
145
|
+
/**
|
|
146
|
+
* Null when the payload cannot make a runnable command — a required positional
|
|
147
|
+
* the args do not carry. The menu then omits the item, the same as for an
|
|
148
|
+
* unmapped tool. `nuxtseo research keywords --site s_x --json` with no topic
|
|
149
|
+
* exits 2 `invalid_cli_input`, which is a broken copy just as surely as an
|
|
150
|
+
* exit 5 is.
|
|
151
|
+
*/
|
|
152
|
+
build: (args: CliArgs) => CliCommand | null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function text(args: CliArgs, ...keys: string[]): string | undefined {
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
const value = args[key]
|
|
158
|
+
if (typeof value === 'string' && value.trim())
|
|
159
|
+
return value.trim()
|
|
160
|
+
}
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// `search analytics <view>` takes the same view vocabulary `gsc_query` does
|
|
165
|
+
// (pages · keywords · countries · devices · timeseries · page-detail ·
|
|
166
|
+
// keyword-detail · analysis), so the copied command answers the question the
|
|
167
|
+
// ejected section asks. It used to hardcode `keywords`, which meant ejecting the
|
|
168
|
+
// Top pages card copied a keywords read.
|
|
169
|
+
const GSC_QUERY_ROW_VIEWS = new Set(['pages', 'keywords', 'countries', 'devices', 'timeseries'])
|
|
170
|
+
|
|
171
|
+
// The three views the CLI refuses without one more argument. Each names the arg
|
|
172
|
+
// it needs on both surfaces, so a payload either carries it or the view has no
|
|
173
|
+
// runnable command.
|
|
174
|
+
const GSC_QUERY_DETAIL_VIEWS: Record<string, { argument: string, flag: string }> = {
|
|
175
|
+
'page-detail': { argument: 'pageUrl', flag: 'page-url' },
|
|
176
|
+
'keyword-detail': { argument: 'keyword', flag: 'keyword' },
|
|
177
|
+
'analysis': { argument: 'preset', flag: 'preset' },
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Analysis presets that additionally require `--brand-terms`; the CLI exits 2
|
|
181
|
+
// `invalid_cli_input` without one.
|
|
182
|
+
const GSC_QUERY_BRAND_PRESETS = new Set(['brand-only', 'non-brand'])
|
|
183
|
+
|
|
184
|
+
function gscQueryCommand(args: CliArgs): CliCommand | null {
|
|
185
|
+
const view = text(args, 'view') ?? 'keywords'
|
|
186
|
+
const detail = GSC_QUERY_DETAIL_VIEWS[view]
|
|
187
|
+
if (!detail && !GSC_QUERY_ROW_VIEWS.has(view))
|
|
188
|
+
return null
|
|
189
|
+
const required = detail ? optionalFlag(args, detail.argument, detail.flag) : null
|
|
190
|
+
if (detail && !required)
|
|
191
|
+
return null
|
|
192
|
+
const flags = [required, optionalFlag(args, 'period', 'period'), optionalFlag(args, 'limit', 'limit')]
|
|
193
|
+
const preset = text(args, 'preset') ?? ''
|
|
194
|
+
if (view === 'analysis' && GSC_QUERY_BRAND_PRESETS.has(preset)) {
|
|
195
|
+
const brandTerms = text(args, 'brandTerms')
|
|
196
|
+
// The brand-only/non-brand presets exit 2 without --brand-terms; absent
|
|
197
|
+
// beats degraded, same rule as the Site guard.
|
|
198
|
+
if (!brandTerms)
|
|
199
|
+
return null
|
|
200
|
+
return cliCommand(['search', 'analytics', view], [...flags, { name: 'brand-terms', value: brandTerms }])
|
|
201
|
+
}
|
|
202
|
+
return cliCommand(['search', 'analytics', view], flags)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `keyword_research` is three commands, not one. Its `type` selects which, and
|
|
207
|
+
* each takes a different required positional: `research` and `serp` take a
|
|
208
|
+
* topic, `rankings` takes a domain. The map used to emit `research keywords`
|
|
209
|
+
* for all three, so the dashboard's Keywords eject (`type: 'rankings'`) copied a
|
|
210
|
+
* keyword-ideas command with no topic at all.
|
|
211
|
+
*/
|
|
212
|
+
function keywordResearchCommand(args: CliArgs): CliCommand | null {
|
|
213
|
+
const type = text(args, 'type') ?? 'research'
|
|
214
|
+
if (type === 'rankings') {
|
|
215
|
+
const domain = text(args, 'domain')
|
|
216
|
+
return domain ? cliCommand(['research', 'rankings', domain], [optionalFlag(args, 'limit', 'limit')]) : null
|
|
217
|
+
}
|
|
218
|
+
const topic = text(args, 'topic', 'keyword', 'seed')
|
|
219
|
+
if (!topic)
|
|
220
|
+
return null
|
|
221
|
+
return type === 'serp'
|
|
222
|
+
? cliCommand(['research', 'serp', topic], [optionalFlag(args, 'depth', 'depth')])
|
|
223
|
+
: cliCommand(['research', 'keywords', topic], [optionalFlag(args, 'limit', 'limit')])
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const CLI_COMMAND_BY_MCP_TOOL: Record<string, CliCommandSpec> = {
|
|
227
|
+
account_usage: { scope: 'account', build: () => cliCommand(['usage'], []) },
|
|
228
|
+
list_sites: { scope: 'account', build: () => cliCommand(['sites', 'list'], []) },
|
|
229
|
+
backlinks: { scope: 'site', build: args => cliCommand(['backlinks', 'recoverable'], [optionalFlag(args, 'limit', 'limit')]) },
|
|
230
|
+
competitors: { scope: 'site', build: () => cliCommand(['research', 'overview'], []) },
|
|
231
|
+
content_decay: { scope: 'site', build: args => cliCommand(['audit', 'content-decay'], [optionalFlag(args, 'limit', 'limit')]) },
|
|
232
|
+
domain_info: { scope: 'site', build: () => cliCommand(['research', 'overview'], []) },
|
|
233
|
+
duplicate_clusters: { scope: 'site', build: () => cliCommand(['audit', 'duplicates'], []) },
|
|
234
|
+
gsc_status: { scope: 'site', build: () => cliCommand(['search', 'status'], []) },
|
|
235
|
+
gsc_query: { scope: 'site', build: gscQueryCommand },
|
|
236
|
+
inspect_url: { scope: 'site', build: (args) => {
|
|
237
|
+
const url = text(args, 'url', 'pageUrl')
|
|
238
|
+
return url ? cliCommand(['page', 'inspect', url], []) : null
|
|
239
|
+
} },
|
|
240
|
+
keyword_research: { scope: 'site', build: keywordResearchCommand },
|
|
241
|
+
link_opportunities: { scope: 'site', build: args => cliCommand(['audit', 'link-opportunities'], [optionalFlag(args, 'limit', 'limit')]) },
|
|
242
|
+
mentions: { scope: 'site', build: args => cliCommand(['mentions', 'list'], [optionalFlag(args, 'limit', 'limit')]) },
|
|
243
|
+
next_actions: { scope: 'site', build: args => cliCommand(['actions', 'list'], [optionalFlag(args, 'limit', 'limit')]) },
|
|
244
|
+
action_details: { scope: 'site', build: (args) => {
|
|
245
|
+
const actionId = text(args, 'actionId', 'id')
|
|
246
|
+
return actionId ? cliCommand(['actions', 'show', actionId], []) : null
|
|
247
|
+
} },
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Build the `nuxtseo` command that answers the same question as an MCP eject
|
|
252
|
+
* payload, or null when the CLI cannot answer it. Single owner of the copied
|
|
253
|
+
* string, the way `serializeMcpCall` owns the MCP one.
|
|
254
|
+
*
|
|
255
|
+
* Three reasons to return null, and each means the menu shows no CLI item: the
|
|
256
|
+
* tool has no shipped command, the command needs a Site and the payload carries
|
|
257
|
+
* none, or the payload is missing an argument the command requires. Absent beats
|
|
258
|
+
* degraded — a copied `nuxtseo actions list --json` with no `--site` exits 5 for
|
|
259
|
+
* every Caller with more than one Site.
|
|
260
|
+
*
|
|
261
|
+
* `--site` sits directly after the command, before any command-specific flags,
|
|
262
|
+
* matching the shape the shipped agent skill documents. `--json` is always last:
|
|
263
|
+
* an agent parses the envelope, and a human reading the command can drop it.
|
|
264
|
+
*/
|
|
265
|
+
export function serializeCliCommand(mcp: EjectMcp): string | null {
|
|
266
|
+
const spec = CLI_COMMAND_BY_MCP_TOOL[mcp.tool]
|
|
267
|
+
if (!spec)
|
|
268
|
+
return null
|
|
269
|
+
const site = typeof mcp.site === 'string' ? mcp.site.trim() : ''
|
|
270
|
+
if (spec.scope === 'site' && !site)
|
|
271
|
+
return null
|
|
272
|
+
const built = spec.build(mcp.args ?? {})
|
|
273
|
+
if (!built)
|
|
274
|
+
return null
|
|
275
|
+
// Structural composition, joined exactly once: root tokens, then --site,
|
|
276
|
+
// then the command's own flags in documented order, then --json. Values are
|
|
277
|
+
// quoted per value at emit time, so an argument containing ' --' can never
|
|
278
|
+
// be mistaken for a flag boundary.
|
|
279
|
+
const segments = [
|
|
280
|
+
...built.tokens.map(shellArg),
|
|
281
|
+
...(site ? [renderFlag({ name: 'site', value: site })] : []),
|
|
282
|
+
...built.flags.map(renderFlag),
|
|
283
|
+
'--json',
|
|
284
|
+
]
|
|
285
|
+
return [CLI_INVOCATION, ...segments].join(' ')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A first-class operation (not an eject-to-code copy), e.g. "Run checks now".
|
|
290
|
+
* Rendered as a leading group above the cURL/MCP/schema items so a row can carry
|
|
291
|
+
* its own actions through the menu it already has, instead of an extra button.
|
|
292
|
+
*/
|
|
293
|
+
export interface EjectAction {
|
|
294
|
+
label: string
|
|
295
|
+
icon?: string
|
|
296
|
+
onSelect: () => void | Promise<void>
|
|
297
|
+
disabled?: boolean
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A scheme-prefixed chat entity reference (`site:s_x`, `page:https://…`). The
|
|
302
|
+
* template type rejects the bare-id mistake at compile time; UiEjectMenu adds a
|
|
303
|
+
* dev-mode warning for the case the type can't catch (a raw URL, whose "https"
|
|
304
|
+
* prefix satisfies the pattern but is no registered scheme).
|
|
305
|
+
*/
|
|
306
|
+
export type ChatEntityRef = `${string}:${string}`
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Entity references the chat hydrate registry can resolve (`chat:hydrate` Nitro
|
|
310
|
+
* hook — site/page/query/issue in pro-gsc, competitor/keyword in pro-dataforseo,
|
|
311
|
+
* cwv in pro-perf, country in pro-gsc). Always build `EjectChat.entity` through
|
|
312
|
+
* these instead of string-interpolating: a bare id ("s_x…") or raw URL parses as
|
|
313
|
+
* no scheme / scheme "https" and the hydrate step silently skips, so the chat
|
|
314
|
+
* answers without the grounding the eject promised.
|
|
315
|
+
*/
|
|
316
|
+
export const chatEntity = {
|
|
317
|
+
site: (id: string): `site:${string}` => `site:${id}`,
|
|
318
|
+
page: (url: string): `page:${string}` => `page:${url}`,
|
|
319
|
+
query: (keyword: string): `query:${string}` => `query:${keyword}`,
|
|
320
|
+
keyword: (keyword: string): `keyword:${string}` => `keyword:${keyword}`,
|
|
321
|
+
competitor: (domain: string): `competitor:${string}` => `competitor:${domain}`,
|
|
322
|
+
cwv: (metric: string): `cwv:${string}` => `cwv:${metric}`,
|
|
323
|
+
issue: (id: string): `issue:${string}` => `issue:${id}`,
|
|
324
|
+
country: (code: string): `country:${string}` => `country:${code}`,
|
|
325
|
+
} as const
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Query keys owned by the chat deep-link contract (plus chat-page-only keys a
|
|
329
|
+
* handoff must never carry over). Kept in sync with
|
|
330
|
+
* `layers/chat/app/internal/schemas/chat-deep-link.ts`.
|
|
331
|
+
*/
|
|
332
|
+
const CHAT_DEEP_LINK_KEYS = ['entity', 'filter', 'seed', 'autoSend', 'chatNonce', 'conversationId', 'context', 'prefill'] as const
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Single builder for the chat deep-link query. Preserves the surface's own query
|
|
336
|
+
* state (filters, tabs, sort) by spreading `currentQuery` minus any stale
|
|
337
|
+
* deep-link keys — ejecting must never wipe the URL state the user set up,
|
|
338
|
+
* because `clear()` on the receiving side can only strip the deep-link keys, not
|
|
339
|
+
* restore what a bare `{ query }` navigation dropped.
|
|
340
|
+
*/
|
|
341
|
+
export function buildChatDeepLinkQuery(
|
|
342
|
+
currentQuery: Record<string, unknown>,
|
|
343
|
+
c: Pick<EjectChat, 'entity' | 'filter' | 'seed' | 'autoSend'>,
|
|
344
|
+
): LocationQueryRaw {
|
|
345
|
+
const rest: LocationQueryRaw = {}
|
|
346
|
+
for (const [k, v] of Object.entries(currentQuery)) {
|
|
347
|
+
if (!(CHAT_DEEP_LINK_KEYS as readonly string[]).includes(k))
|
|
348
|
+
rest[k] = v as LocationQueryRaw[string]
|
|
349
|
+
}
|
|
350
|
+
if (c.entity)
|
|
351
|
+
rest.entity = c.entity
|
|
352
|
+
if (c.filter)
|
|
353
|
+
rest.filter = JSON.stringify(c.filter)
|
|
354
|
+
if (c.seed)
|
|
355
|
+
rest.seed = c.seed
|
|
356
|
+
if (c.autoSend)
|
|
357
|
+
rest.autoSend = '1'
|
|
358
|
+
return rest
|
|
359
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { InjectionKey } from 'vue'
|
|
2
|
+
import { inject } from 'vue'
|
|
3
|
+
|
|
4
|
+
/** Code-eject actions a UiEjectMenu can fire. Mirrors the menu's item set. */
|
|
5
|
+
export type EjectTrackAction = 'copy-cli' | 'copy-mcp' | 'open-chat' | 'view-schema'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A code-eject was invoked from a UiEjectMenu. `action` + the implied
|
|
9
|
+
* surface='eject-menu' are filled by the menu itself; `kind`/`variant` are
|
|
10
|
+
* optional call-site enrichment (the menu can't know its domain entity).
|
|
11
|
+
*/
|
|
12
|
+
export interface EjectTrackEvent {
|
|
13
|
+
action: EjectTrackAction
|
|
14
|
+
/** Domain entity the menu was attached to, e.g. 'keyword' | 'competitor' | 'cwv'. */
|
|
15
|
+
kind?: string
|
|
16
|
+
/** Sub-variant within the kind (page goal, competitor focus, …). */
|
|
17
|
+
variant?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type EjectTracker = (event: EjectTrackEvent) => void
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Provide/inject seam so design-system's UiEjectMenu can emit eject telemetry
|
|
24
|
+
* without importing the pro-features tracker (layer rule: design-system MUST
|
|
25
|
+
* NOT depend on runtime services). pro-features provides the impl via an app
|
|
26
|
+
* plugin; absent a provider this no-ops.
|
|
27
|
+
*/
|
|
28
|
+
export const ejectTrackerKey: InjectionKey<EjectTracker> = Symbol('eject-tracker')
|
|
29
|
+
|
|
30
|
+
const noop: EjectTracker = () => {}
|
|
31
|
+
|
|
32
|
+
export function useEjectTracker(): EjectTracker {
|
|
33
|
+
return inject(ejectTrackerKey, noop)
|
|
34
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The onboarding "About you" questions: display data only (id/label), ordered.
|
|
2
|
+
// The ids are shared with gscdump.com and Request Indexing, which keep a copy of
|
|
3
|
+
// this file. Change an id in all three repositories or not at all.
|
|
4
|
+
// The goal list is per product and lives in onboarding-intents.ts.
|
|
5
|
+
import { ONBOARDING_INTENTS } from './onboarding-intents'
|
|
6
|
+
|
|
7
|
+
export const ONBOARDING_ROLES = [
|
|
8
|
+
{ id: 'solo', label: 'Solo developer' },
|
|
9
|
+
{ id: 'agency', label: 'Agency or freelancer' },
|
|
10
|
+
{ id: 'in-house', label: 'In-house team' },
|
|
11
|
+
{ id: 'tool-builder', label: 'Building an SEO tool' },
|
|
12
|
+
{ id: 'other', label: 'Something else' },
|
|
13
|
+
] as const
|
|
14
|
+
|
|
15
|
+
export const ONBOARDING_SITE_COUNTS = [
|
|
16
|
+
{ id: '1', label: '1' },
|
|
17
|
+
{ id: '2-5', label: '2–5' },
|
|
18
|
+
{ id: '6-20', label: '6–20' },
|
|
19
|
+
{ id: '21+', label: '21+' },
|
|
20
|
+
] as const
|
|
21
|
+
|
|
22
|
+
export const ONBOARDING_SEO_EXPERIENCE = [
|
|
23
|
+
{ id: 'new', label: 'New to it' },
|
|
24
|
+
{ id: 'basics', label: 'Know the basics' },
|
|
25
|
+
{ id: 'professional', label: 'Do it for a living' },
|
|
26
|
+
] as const
|
|
27
|
+
|
|
28
|
+
export type OnboardingRole = (typeof ONBOARDING_ROLES)[number]['id']
|
|
29
|
+
export type OnboardingSiteCount = (typeof ONBOARDING_SITE_COUNTS)[number]['id']
|
|
30
|
+
export type OnboardingSeoExperience = (typeof ONBOARDING_SEO_EXPERIENCE)[number]['id']
|
|
31
|
+
|
|
32
|
+
export const ONBOARDING_ROLE_IDS = ONBOARDING_ROLES.map(option => option.id) as [OnboardingRole, ...OnboardingRole[]]
|
|
33
|
+
export const ONBOARDING_SITE_COUNT_IDS = ONBOARDING_SITE_COUNTS.map(option => option.id) as [OnboardingSiteCount, ...OnboardingSiteCount[]]
|
|
34
|
+
export const ONBOARDING_SEO_EXPERIENCE_IDS = ONBOARDING_SEO_EXPERIENCE.map(option => option.id) as [OnboardingSeoExperience, ...OnboardingSeoExperience[]]
|
|
35
|
+
export const ONBOARDING_INTENT_IDS = ONBOARDING_INTENTS.map(intent => intent.id) as [string, ...string[]]
|
|
36
|
+
|
|
37
|
+
/** Free text under the goals question. The server schema enforces the same limit. */
|
|
38
|
+
export const ONBOARDING_OTHER_MAX_LENGTH = 500
|
|
39
|
+
|
|
40
|
+
export const ONBOARDING_QUESTIONS = {
|
|
41
|
+
role: 'What best describes you?',
|
|
42
|
+
siteCount: 'How many websites do you look after?',
|
|
43
|
+
seoExperience: 'How comfortable are you with SEO?',
|
|
44
|
+
intents: 'What do you want to do with Nuxt SEO Pro?',
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every string `UiOnboardingAnswers` renders besides the option labels. COPY.md
|
|
49
|
+
* (Onboarding About you) owns the defaults. A site that extends the layer
|
|
50
|
+
* passes its own through the component's `copy` prop, one key or all of them.
|
|
51
|
+
*/
|
|
52
|
+
export interface OnboardingAnswersCopy {
|
|
53
|
+
roleQuestion: string
|
|
54
|
+
siteCountQuestion: string
|
|
55
|
+
seoExperienceQuestion: string
|
|
56
|
+
intentsQuestion: string
|
|
57
|
+
intentsHint: string
|
|
58
|
+
otherLabel: string
|
|
59
|
+
otherPlaceholder: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const ONBOARDING_ANSWERS_COPY: Readonly<OnboardingAnswersCopy> = {
|
|
63
|
+
roleQuestion: ONBOARDING_QUESTIONS.role,
|
|
64
|
+
siteCountQuestion: ONBOARDING_QUESTIONS.siteCount,
|
|
65
|
+
seoExperienceQuestion: ONBOARDING_QUESTIONS.seoExperience,
|
|
66
|
+
intentsQuestion: ONBOARDING_QUESTIONS.intents,
|
|
67
|
+
intentsHint: 'Pick any that fit.',
|
|
68
|
+
otherLabel: 'Anything else? Optional.',
|
|
69
|
+
otherPlaceholder: 'Tell us what you\'re after',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Label lookup for stored ids. An unknown id renders as itself. */
|
|
73
|
+
export function onboardingAnswerLabel(options: readonly { id: string, label: string }[], id: string): string {
|
|
74
|
+
return options.find(option => option.id === id)?.label ?? id
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The help step's unsaved answers. A single-choice question with no chip picked is `undefined`. */
|
|
78
|
+
export interface OnboardingAnswersDraft {
|
|
79
|
+
role?: OnboardingRole
|
|
80
|
+
siteCount?: OnboardingSiteCount
|
|
81
|
+
seoExperience?: OnboardingSeoExperience
|
|
82
|
+
intents: string[]
|
|
83
|
+
other: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function emptyOnboardingAnswersDraft(): OnboardingAnswersDraft {
|
|
87
|
+
return { intents: [], other: '' }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when the user picked a chip or wrote text, so there is something to save. */
|
|
91
|
+
export function hasOnboardingAnswers(draft: OnboardingAnswersDraft): boolean {
|
|
92
|
+
return !!(draft.role || draft.siteCount || draft.seoExperience || draft.intents.length || draft.other.trim())
|
|
93
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The onboarding "How can we help?" options — display data only (id/label/icon),
|
|
2
|
+
// ordered. Lives in design-system (alongside the UiWizard* family + the in-memory
|
|
3
|
+
// onboarding service) so both the live pro flow and the brand-kit showcase render
|
|
4
|
+
// the same list. The app maps id → internal focus separately (teams/create.vue).
|
|
5
|
+
export interface OnboardingIntent {
|
|
6
|
+
id: string
|
|
7
|
+
label: string
|
|
8
|
+
icon: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const ONBOARDING_INTENTS: OnboardingIntent[] = [
|
|
12
|
+
{ id: 'competitors', label: 'Beat my competitors', icon: 'chart-line' },
|
|
13
|
+
{ id: 'visitors', label: 'Get more visitors', icon: 'search' },
|
|
14
|
+
{ id: 'technical', label: 'Improve technical SEO', icon: 'shield-check' },
|
|
15
|
+
{ id: 'ai', label: 'Show up in AI answers', icon: 'bot' },
|
|
16
|
+
{ id: 'recover', label: 'Recover lost traffic', icon: 'i-lucide-rotate-ccw' },
|
|
17
|
+
{ id: 'speed', label: 'Speed up my site', icon: 'i-lucide-gauge' },
|
|
18
|
+
{ id: 'authority', label: 'Earn backlinks & mentions', icon: 'i-lucide-megaphone' },
|
|
19
|
+
]
|