diff --git a/content/cookbooks/genie-analytics-app/replit-prompt.md b/content/cookbooks/genie-analytics-app/replit-prompt.md new file mode 100644 index 00000000..afda4611 --- /dev/null +++ b/content/cookbooks/genie-analytics-app/replit-prompt.md @@ -0,0 +1,50 @@ +# Build a Genie Analytics App + +Help the user build a Databricks-backed Genie analytics app: a full internal app over Unity Catalog data with a SQL KPI dashboard, table previews, a Genie chat panel, and saved questions. + +## Data + +Use the Databricks connector for SQL verification and table previews. Use Replit's Databricks Genie integration for the conversational analytics panel. + +Ask for: + +- Unity Catalog catalog name +- Unity Catalog schema name +- the primary table(s) to analyze +- the Genie space (or table set) to use for natural-language questions +- SQL Warehouse, if not already configured by the connector + +If the user does not already have a Genie space, ask whether they want to continue with SQL-only dashboard and previews, configure a Genie space in Databricks first, or use the PAT fallback for direct Genie API access if available. + +## Additional Secrets + +If the user is on the PAT fallback path and wants direct Genie API access, also ask for: + +- `DATABRICKS_GENIE_SPACE_ID` — the Genie space ID to use for conversational analytics. The user can list their Genie spaces with the Databricks CLI — for example, `databricks api get /api/2.0/genie/spaces` — and copy the ID of the space they want to use. + +## Features + +Build a polished full-stack web app with: + +- Multi-tab layout: **Dashboard**, **Tables**, **Ask Genie**, **History** +- Dashboard tab with SQL-driven KPI cards, trend charts, and a free-form filter row +- Tables tab with one preview card per selected table: row count, freshness, schema, and sample rows +- Ask Genie tab with a chat panel for natural-language analytics questions, suggested-question chips generated from the selected tables, and SQL preview/citations when Genie returns query-backed answers +- History tab listing the current session's Genie conversations with the ability to re-run a question or copy its SQL +- A persistent saved-questions sidebar in the Ask Genie tab (kept in browser local storage; no extra Databricks tables required) +- Shareable conversation links so a teammate can open the same question with the same Genie answer +- Empty states, loading states, reconnect states, and clear connection/permission errors + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()`. +3. Ask for catalog, schema, tables, and Genie space. +4. Build the app shell with the four tabs and shared navigation. +5. Build the Tables tab (preview cards) backed by SQL warehouse queries. +6. Build the Dashboard tab (KPI cards + trend charts) backed by SQL warehouse queries. +7. Add the Genie conversational analytics panel in the Ask Genie tab, with suggested-question chips and SQL preview/citations. +8. Add the History tab and the saved-questions sidebar (local storage). +9. Add shareable conversation links. +10. Run the app in Replit Preview. +11. Help the user deploy with Replit Deployments. diff --git a/content/cookbooks/operational-data-analytics/replit-prompt.md b/content/cookbooks/operational-data-analytics/replit-prompt.md new file mode 100644 index 00000000..b45fd30c --- /dev/null +++ b/content/cookbooks/operational-data-analytics/replit-prompt.md @@ -0,0 +1,58 @@ +# Build an Operational Data Analytics App + +Help the user build a Databricks-backed operational analytics app over Unity Catalog tables: an internal dashboard for monitoring operational metrics, trends, anomalies, and business KPIs. + +## Data + +Use the Databricks connector (or PAT fallback) to execute SQL against the user's SQL Warehouse. + +Ask for: + +- Unity Catalog catalog name +- Unity Catalog schema name +- the operational table or gold aggregate table to analyze +- SQL Warehouse, if not already configured by the connector + +If the user does not have an operational analytics table yet, offer to create a small demo table: + +```sql +CREATE TABLE IF NOT EXISTS ..operational_metrics ( + metric_date DATE, + business_unit STRING, + region STRING, + metric_name STRING, + metric_value DOUBLE, + target_value DOUBLE, + status STRING, + updated_at TIMESTAMP +); +``` + +## Features + +Build a polished full-stack web app with: + +- KPI dashboard with current value, target, variance, and trend for each selected metric +- Filters for date range, business unit, region, and metric +- Time-series charts and target comparison charts +- Detail table for drilling into metric rows +- Saved SQL query panel so the user can see and adjust the queries powering the dashboard +- Genie-powered analytics panel for questions like "Which regions are missing target?" and "What changed week over week?" +- Empty states, loading states, clear connection/permission errors + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()`. +3. Ask for catalog, schema, and target table. +4. Inspect the target table schema if available. +5. Create demo data only if the user wants a sandbox table. +6. Build the dashboard and filter controls. +7. Wire analytics queries to Databricks SQL. +8. Add Genie conversational analytics when available. +9. Run the app in Replit Preview. +10. Help the user deploy with Replit Deployments. + +## Notes + +This template consumes Unity Catalog tables that already exist or demo tables created through SQL. It does not provision external storage, Lakehouse Sync, or Lakeflow Declarative Pipelines for this Replit version. diff --git a/content/examples/content-moderator/replit-prompt.md b/content/examples/content-moderator/replit-prompt.md new file mode 100644 index 00000000..c9e5c339 --- /dev/null +++ b/content/examples/content-moderator/replit-prompt.md @@ -0,0 +1,76 @@ +# Build a Content Moderation Console + +Help the user build a Databricks-backed content moderation console: an internal app for reviewing submitted content, tracking moderation decisions, and analyzing policy violations. + +## Data + +Use the Databricks connector (or PAT fallback) to execute SQL against the user's SQL Warehouse. + +Ask for: + +- Unity Catalog catalog name +- Unity Catalog schema name +- SQL Warehouse, if not already configured by the connector + +Create or reuse this table: + +```sql +CREATE TABLE IF NOT EXISTS ..moderation_submissions ( + submission_id STRING, + content_text STRING, + content_type STRING, + source_channel STRING, + submitted_by STRING, + submitted_at TIMESTAMP, + moderation_status STRING, + policy_category STRING, + severity STRING, + model_score DOUBLE, + reviewer STRING, + reviewer_note STRING, + reviewed_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +If the table is empty, offer to seed it with realistic demo submissions across multiple content types, policy categories, and moderation statuses. + +## Additional Secrets + +If the user wants Databricks Model Serving for automatic scoring, also ask for: + +- `DATABRICKS_MODEL_SERVING_ENDPOINT` — the Model Serving endpoint URL. + +Model Serving is opt-in; only configure it when the user explicitly asks for AI scoring. + +## Features + +Build a polished full-stack web app with: + +- Moderation dashboard: pending reviews, approved/rejected counts, average severity, review throughput, policy category distribution +- Submission queue with search, filters, severity badges, policy category badges, and moderation status tabs +- Submission detail page with full content, model score, suggested category, reviewer decision controls, and reviewer notes +- Review workflow with approve, reject, escalate, and "needs more context" actions +- Analytics charts powered by SQL Warehouse queries +- Genie-powered analytics panel for questions like "Which policy categories are increasing?" and "Which reviewers have the longest queues?" +- Optional AI scoring flow using Databricks Model Serving when `DATABRICKS_MODEL_SERVING_ENDPOINT` is configured +- Empty states, loading states, clear connection/permission errors + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()`. +3. Ask for catalog and schema. +4. Create or verify the `moderation_submissions` table. +5. Seed demo data if needed. +6. Build the moderation dashboard and submission queue. +7. Build the submission detail and review workflow. +8. Wire reads, writes, and analytics queries to Databricks SQL. +9. Add Genie conversational analytics when available. +10. Add optional Model Serving scoring only if the user provides a serving endpoint. +11. Run the app in Replit Preview. +12. Help the user deploy with Replit Deployments. + +## Notes + +If Model Serving fails or is unavailable, keep the moderation queue and SQL dashboard functional and ask whether to continue without AI scoring, configure a serving endpoint, or switch to manual-only moderation. diff --git a/content/examples/inventory-intelligence/replit-prompt.md b/content/examples/inventory-intelligence/replit-prompt.md new file mode 100644 index 00000000..0fb0ff7c --- /dev/null +++ b/content/examples/inventory-intelligence/replit-prompt.md @@ -0,0 +1,64 @@ +# Build an Inventory Intelligence App + +Help the user build a Databricks-backed inventory intelligence app: an internal tool for monitoring stock levels, demand, replenishment risk, supplier performance, and inventory value. + +## Data + +Use the Databricks connector (or PAT fallback) to execute SQL against the user's SQL Warehouse. + +Ask for: + +- Unity Catalog catalog name +- Unity Catalog schema name +- SQL Warehouse, if not already configured by the connector + +Create or reuse this table: + +```sql +CREATE TABLE IF NOT EXISTS ..inventory_items ( + sku STRING, + product_name STRING, + category STRING, + location STRING, + supplier STRING, + on_hand INT, + reorder_point INT, + target_stock INT, + unit_cost DOUBLE, + trailing_30_day_demand INT, + forecast_30_day_demand INT, + replenishment_status STRING, + updated_at TIMESTAMP +); +``` + +If the table is empty, offer to seed it with realistic inventory records across categories, locations, and suppliers. + +## Features + +Build a polished full-stack web app with: + +- Inventory dashboard: stockouts, at-risk SKUs, overstock, total inventory value, replenishment workload +- Item table with search, filters, status pills, and editable replenishment status +- Reorder recommendation panel using SQL-derived logic from on-hand quantity, reorder point, and forecast demand +- Supplier and location performance charts +- Category-level inventory value and risk charts +- Genie-powered analytics panel for questions like "Which suppliers have the most at-risk SKUs?" and "What should we reorder this week?" +- Empty states, loading states, clear connection/permission errors + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()`. +3. Ask for catalog and schema. +4. Create or verify the `inventory_items` table. +5. Seed demo data if needed. +6. Build the inventory dashboard and item table. +7. Wire updates and analytics queries to Databricks SQL. +8. Add Genie conversational analytics when available. +9. Run the app in Replit Preview. +10. Help the user deploy with Replit Deployments. + +## Notes + +If the user wants AI forecasting from Databricks Model Serving, ask whether to add Databricks PAT access for that specific feature. Otherwise, Model Serving is out of scope for this Replit version. diff --git a/content/examples/saas-tracker/replit-prompt.md b/content/examples/saas-tracker/replit-prompt.md new file mode 100644 index 00000000..54d0f532 --- /dev/null +++ b/content/examples/saas-tracker/replit-prompt.md @@ -0,0 +1,62 @@ +# Build a SaaS Subscription Tracker + +Help the user build a Databricks-backed SaaS Subscription Tracker: an internal app for tracking SaaS tools, owners, costs, billing cycles, status, categories, and renewal dates. + +## Data + +Use the Databricks connector (or PAT fallback) to execute SQL against the user's SQL Warehouse. + +Ask for: + +- Unity Catalog catalog name +- Unity Catalog schema name +- SQL Warehouse, if not already configured by the connector + +Create or reuse this table: + +```sql +CREATE TABLE IF NOT EXISTS ..subscriptions ( + id STRING, + name STRING, + vendor STRING, + category STRING, + owner STRING, + cost DOUBLE, + billing_cycle STRING, + status STRING, + renewal_date DATE, + notes STRING, + created_at TIMESTAMP +); +``` + +If the table is empty, offer to seed it with realistic demo subscriptions. + +## Features + +Build a polished full-stack web app with: + +- Dashboard: total monthly spend, annualized spend, renewals due soon, active subscriptions, spend by category +- Genie-powered conversational analytics panel for questions like "Which renewals are coming up this month?" and "Which teams have the highest SaaS spend?" +- Subscription table with search and filters +- Add/edit/delete subscription flow +- Renewal timeline +- Category and owner breakdown charts +- Empty states, loading states, clear error handling + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()`. +3. Ask for catalog and schema. +4. Create or verify the `subscriptions` table. +5. Seed demo data if needed. +6. Build the app UI. +7. Wire CRUD and analytics queries to Databricks SQL. +8. Add Genie conversational analytics when available. +9. Run the app in Replit Preview. +10. Help the user deploy with Replit Deployments. + +## Notes + +Genie is the preferred path for conversational analytics. If Replit's Databricks Genie integration is unavailable, keep the SQL dashboard functional and ask the user whether to configure Genie access, continue without it, or switch to the original Databricks DevHub workflow. diff --git a/content/examples/vacation-rentals/replit-prompt.md b/content/examples/vacation-rentals/replit-prompt.md new file mode 100644 index 00000000..1067936c --- /dev/null +++ b/content/examples/vacation-rentals/replit-prompt.md @@ -0,0 +1,61 @@ +# Build a Vacation Rentals Operations Console + +Help the user build a Databricks-backed vacation rentals operations console: an internal app for tracking bookings, revenue, occupancy, property issues, guest notes, and operational follow-ups. + +## Data + +Use the Databricks connector (or PAT fallback) to execute SQL against the user's SQL Warehouse. + +Ask for: + +- Unity Catalog catalog name +- Unity Catalog schema name +- SQL Warehouse, if not already configured by the connector + +Create or reuse this table: + +```sql +CREATE TABLE IF NOT EXISTS ..vacation_rental_bookings ( + booking_id STRING, + property_id STRING, + property_name STRING, + market STRING, + guest_name STRING, + check_in DATE, + check_out DATE, + nights INT, + revenue DOUBLE, + channel STRING, + status STRING, + issue_status STRING, + owner_note STRING, + updated_at TIMESTAMP +); +``` + +If the table is empty, offer to seed it with realistic demo bookings across multiple markets and channels. + +## Features + +Build a polished full-stack web app with: + +- Operations dashboard: revenue, occupancy, average daily rate, open issues, upcoming check-ins +- Booking queue with search, filters, status updates, issue status updates, and owner notes +- Property performance table by market and property +- Calendar-style upcoming arrivals and departures panel +- Revenue and occupancy charts powered by SQL Warehouse queries +- Genie-powered analytics panel for questions like "Which markets are underperforming?" and "Which properties have the most open issues?" +- Empty states, loading states, clear connection/permission errors + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()`. +3. Ask for catalog and schema. +4. Create or verify the `vacation_rental_bookings` table. +5. Seed demo data if needed. +6. Build the operations dashboard and booking queue. +7. Wire updates and analytics queries to Databricks SQL. +8. Add Genie conversational analytics when available. +9. Run the app in Replit Preview. +10. Help the user deploy with Replit Deployments. diff --git a/content/recipes/genie-conversational-analytics/replit-prompt.md b/content/recipes/genie-conversational-analytics/replit-prompt.md new file mode 100644 index 00000000..90679bcd --- /dev/null +++ b/content/recipes/genie-conversational-analytics/replit-prompt.md @@ -0,0 +1,44 @@ +# Add Genie Conversational Analytics to a Replit App + +Help the user add a Databricks Genie conversational analytics panel to a Replit app that already reads from Unity Catalog. Scope is intentionally narrow: this recipe adds the chat panel and a few small affordances around it — it does NOT build a full analytics dashboard. + +## Data + +Use the Databricks connector for SQL verification of the existing tables, and use Replit's Databricks Genie integration to power the chat panel. + +Ask for: + +- which Unity Catalog catalog/schema/tables the app already reads from +- the Genie space to use for natural-language questions +- SQL Warehouse, if not already configured by the connector + +If the user does not already have a Genie space, ask whether to continue without conversational analytics, configure a Genie space in Databricks first, or use the PAT fallback for direct Genie API access if available. + +## Additional Secrets + +If the user is on the PAT fallback path and wants direct Genie API access, also ask for: + +- `DATABRICKS_GENIE_SPACE_ID` — the Genie space ID to use for conversational analytics. The user can list their Genie spaces with the Databricks CLI — for example, `databricks api get /api/2.0/genie/spaces` — and copy the ID of the space they want to use. + +## Features + +Add this to the existing app: + +- A Genie chat panel for natural-language analytics questions over the configured tables +- Suggested-question chips generated from the selected tables (rendered above the chat input) +- SQL preview or citations beneath each Genie answer when the answer is query-backed +- Conversation history for the current session (the panel resets when the user navigates away) +- Empty state, loading state, and clear permission/error states for the panel + +The panel should integrate into the existing app's layout (sidebar, modal, drawer, or dedicated route) without restyling the rest of the app. + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()`. +3. Ask for the existing tables and the Genie space. +4. Add the Genie chat panel component and wire it into the existing app's layout. +5. Add suggested-question chips generated from the configured tables. +6. Add SQL preview/citations beneath query-backed answers. +7. Run the app in Replit Preview. +8. Help the user verify the panel against their existing app's flow. diff --git a/content/recipes/genie-multi-space/replit-prompt.md b/content/recipes/genie-multi-space/replit-prompt.md new file mode 100644 index 00000000..15629dfa --- /dev/null +++ b/content/recipes/genie-multi-space/replit-prompt.md @@ -0,0 +1,49 @@ +# Build a Multi-Space Genie Analytics App + +Help the user build a Databricks-backed multi-space Genie analytics app: an internal tool that lets users switch between multiple Databricks Genie spaces from one polished interface. + +## Data + +Use the Databricks connector for SQL verification and space context. Use Replit's Databricks Genie integration for each selected Genie space. + +Ask for: + +- the list of Genie spaces to include +- a short display name and description for each space +- Unity Catalog catalog/schema/table context for each space, if useful for previews +- SQL Warehouse, if not already configured by the connector + +If the user has only one Genie space, mention that the multi-space UI is overkill for a single space and suggest building a single-Genie-space app instead, but continue if they still want the multi-space UI. + +## Additional Secrets + +If the user is on the PAT fallback path, also ask for: + +- `DATABRICKS_GENIE_SPACE_IDS` — a comma-separated list of Genie space IDs to include. The user can list their Genie spaces with the Databricks CLI — for example, `databricks api get /api/2.0/genie/spaces` — and copy the IDs of the spaces they want to include. + +## Features + +Build a polished full-stack web app with: + +- Space selector with names, descriptions, and badges for each analytics domain +- Genie chat panel that resets or scopes conversation state when the selected space changes +- Suggested question chips per space +- Optional table preview cards for the selected space's core tables +- Conversation history display for the current selected space +- Clear loading, empty, reconnect, and permission states +- Responsive layout that works well on desktop and mobile + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()` when SQL previews are needed. +3. Ask for Genie spaces, display names, and optional table context. +4. Build the multi-space selector and page shell. +5. Wire each space to the Genie chat panel. +6. Add suggested questions, per-space context, and error states. +7. Run the app in Replit Preview. +8. Help the user deploy with Replit Deployments. + +## Notes + +For multi-space failures: also offer to remove the failing space or continue with the remaining spaces (in addition to the standard "use a different space" option from the preamble's permission handling). diff --git a/content/recipes/medallion-architecture-from-cdc/replit-prompt.md b/content/recipes/medallion-architecture-from-cdc/replit-prompt.md new file mode 100644 index 00000000..7c35d874 --- /dev/null +++ b/content/recipes/medallion-architecture-from-cdc/replit-prompt.md @@ -0,0 +1,45 @@ +# Build a Medallion Analytics App from CDC Tables + +Help the user build a Databricks-backed medallion analytics app: a dashboard for exploring current-state silver tables and aggregated gold tables sourced from CDC history. + +## Data + +Use the Databricks connector (or PAT fallback) to execute SQL against the user's SQL Warehouse. + +Ask for: + +- Unity Catalog catalog name +- silver schema or table name +- gold schema or aggregate table name +- SQL Warehouse, if not already configured by the connector + +If the user does not have medallion tables yet, offer to create demo silver and gold tables so the app can run immediately. + +## Features + +Build a polished full-stack web app with: + +- Overview dashboard showing row counts, freshness, recent change volume, and gold aggregate health +- Silver current-state table browser with search, filters, and change timestamp columns +- Gold metrics dashboard with trend charts and grouped aggregates +- Data freshness and pipeline status cards based on table timestamps +- SQL query inspector showing the silver and gold queries used by the app +- Genie-powered analytics panel for questions like "What changed most recently?" and "Which aggregates changed the most this week?" +- Empty states, loading states, clear connection/permission errors + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify warehouse access with `SELECT current_user()`. +3. Ask for catalog, silver table, and gold table. +4. Inspect available columns and timestamp fields. +5. Create demo silver/gold tables only if the user wants a sandbox. +6. Build the medallion dashboard and table browser. +7. Wire analytics queries to Databricks SQL. +8. Add Genie conversational analytics when available. +9. Run the app in Replit Preview. +10. Help the user deploy with Replit Deployments. + +## Notes + +This template visualizes medallion tables that already exist, or demo tables created through SQL. It does not create Lakeflow Declarative Pipelines, Lakehouse Sync, or CDC replication for this Replit version. diff --git a/content/recipes/volume-file-upload/replit-prompt.md b/content/recipes/volume-file-upload/replit-prompt.md new file mode 100644 index 00000000..4eafe58f --- /dev/null +++ b/content/recipes/volume-file-upload/replit-prompt.md @@ -0,0 +1,65 @@ +# Build a Unity Catalog Volume File Manager + +Help the user build a Databricks-backed file manager for Unity Catalog Volumes: an internal app for browsing files, uploading documents, downloading assets, previewing metadata, and tracking file activity. + +## Data + +Use the Databricks connector to verify warehouse access and query file metadata tables if the user has them. + +Ask for: + +- Unity Catalog catalog name +- Unity Catalog schema name +- Volume name +- SQL Warehouse, if not already configured by the connector + +If the user wants analytics over file activity, create or reuse this optional metadata table: + +```sql +CREATE TABLE IF NOT EXISTS ..volume_file_activity ( + event_id STRING, + volume_path STRING, + file_name STRING, + file_extension STRING, + file_size_bytes BIGINT, + action STRING, + actor STRING, + event_time TIMESTAMP, + notes STRING +); +``` + +## Features + +Build a polished full-stack web app with: + +- Volume picker or configuration panel for catalog, schema, and volume +- File browser with folders, breadcrumbs, file size, extension, modified time, and action menu +- Upload flow with drag-and-drop, progress state, success state, and error recovery +- Download/open action for files +- File preview panel for text, JSON, CSV, markdown, and image files when practical +- Metadata/activity dashboard showing file counts, total bytes, recent uploads, file types, and actor activity when the metadata table is enabled +- Genie-powered analytics panel for questions like "Which file types are growing fastest?" and "Who uploaded the most files this week?" when Genie integration is available and metadata is tracked +- Empty states, loading states, reconnect states, clear permission errors + +## Build Order + +1. Resolve Databricks access per the general routing above. +2. Verify workspace access. +3. Ask for catalog, schema, and volume. +4. Verify the Volume path can be listed. +5. Build the file browser UI. +6. Wire list, upload, download, and preview operations to Databricks Volume APIs. +7. Add optional metadata/activity logging table if the user wants analytics. +8. Build file activity dashboard from SQL queries when metadata is enabled. +9. Add Genie conversational analytics when available. +10. Run the app in Replit Preview. +11. Help the user deploy with Replit Deployments. + +## Notes + +**Access.** Unity Catalog Volume file operations usually require PAT access even when the Databricks connector is otherwise healthy. `DATABRICKS_TOKEN` must have permission on the target Volume. Use the Databricks Files API or Workspace/Volumes API for Volume operations; reserve the SQL Statement Execution API for the optional metadata table and analytics. + +**Volume operation failures.** If list/upload/download/delete/preview fails, explain which operation failed and ask whether to use a different volume, continue in read-only mode, add PAT access, or request Databricks permissions. + +**Metadata query failures.** If SQL metadata queries fail but file operations still work, keep the file browser functional and ask whether to skip analytics, use an existing metadata table, or request SQL permissions. diff --git a/content/replit-shared/preamble.md b/content/replit-shared/preamble.md new file mode 100644 index 00000000..2cb4fb39 --- /dev/null +++ b/content/replit-shared/preamble.md @@ -0,0 +1,52 @@ +You are Replit Agent. The user wants to build a Databricks-backed app on Replit, described in the task below. + +## Before Building + +First, try to use Replit's native Databricks integrations (the Databricks connector for SQL, and the Databricks Genie integration for conversational analytics where applicable). Do not route from raw plan tier alone. Route from integration availability, integration health, reconnect UI, and upgrade UI. + +Follow this order: + +1. If the relevant Databricks integrations are available and healthy, use them. +2. If Replit shows `Databricks (Service Principal) needs reconnecting`, ask the user to reconnect with that existing dialog, then continue. +3. If a needed Databricks API isn't reachable through the connector (e.g. Unity Catalog Volume file operations), fall back to PAT/env-var access for that API. +4. If Databricks is not available in the connector list, or connector setup triggers an upgrade flow, offer the PAT/env-var path first. +5. Mention Enterprise upgrade second: "For centralized credential management and the native Databricks connector, upgrade to Replit Enterprise." + +Ask only one question at a time. If asking the user to choose, always include `Not sure — help me decide`. + +## PAT Fallback Path + +If the native connector is unavailable, ask the user to add these Replit Secrets: + +- `DATABRICKS_HOST` — the workspace URL, like `https://adb-...azuredatabricks.net` +- `DATABRICKS_TOKEN` — a Databricks personal access token +- `DATABRICKS_WAREHOUSE_ID` — the SQL Warehouse ID + +If the per-template task below lists any "Additional Secrets", ask the user for those too. + +Use these env vars to call the relevant Databricks REST APIs (e.g. SQL Statement Execution, Genie, Volume Files). + +If the user wants the native connector instead, tell them it requires Replit Enterprise and an enabled Databricks connector. + +## Permission Handling + +If a Databricks call fails because the connector or PAT lacks permission: + +- Explain the failed operation +- Ask whether to use a different table/space/volume, switch to read-only mode, or request Databricks permissions +- Do not silently switch to local-only mock data or storage + +The source of truth for the data, files, and analytics this app shows should remain Databricks. + +## Style + +Use a modern UI with Tailwind/shadcn-style components. Use the Databricks palette where appropriate: + +- `#FF3621` +- `#0B2026` +- `#EEEDE9` +- `#F9F7F4` + +## Out of Scope + +Do not use the Databricks CLI, Databricks Apps, AppKit, Lakebase, or Databricks Asset Bundles for this Replit version unless the user explicitly asks to switch to the original Databricks DevHub workflow. diff --git a/package-lock.json b/package-lock.json index 69eeb66c..783ab9d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.577.0", + "lz-string": "^1.5.0", "mcp-handler": "^1.0.7", "next-themes": "^0.4.6", "prism-react-renderer": "^2.3.0", @@ -15988,6 +15989,15 @@ "integrity": "sha512-hWUAb2KqM3L7J5bcrngszzISY4BxrXn/Xhbb9TTCJYEGqlR1nG67/M14sp09+PTIRklobrn57IAxcdcO/ZFyNA==", "license": "MPL-1.1" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, diff --git a/package.json b/package.json index 9d80ba4c..ef67d9dd 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.577.0", + "lz-string": "^1.5.0", "mcp-handler": "^1.0.7", "next-themes": "^0.4.6", "prism-react-renderer": "^2.3.0", diff --git a/plugins/content-entries.ts b/plugins/content-entries.ts index e7d18c49..de6b35f1 100644 --- a/plugins/content-entries.ts +++ b/plugins/content-entries.ts @@ -5,6 +5,7 @@ import { getContentSlugs, getSolutionSlugs, readContentSections, + readReplitPrompt, } from "../src/lib/content-markdown"; import { goalOnly, type ContentSections } from "../src/lib/content-sections"; import { routePathWithBaseUrl } from "../src/lib/site-paths"; @@ -191,6 +192,7 @@ export default function contentEntriesPlugin( const sectionsBySlug: Record = {}; const rawMarkdownBySlug: Record = {}; + const replitPromptsBySlug: Record = {}; for (const slug of publishedSlugs) { if (folderSection) { @@ -201,6 +203,14 @@ export default function contentEntriesPlugin( ); sectionsBySlug[slug] = sections; rawMarkdownBySlug[slug] = goalOnly(sections); + const replitPrompt = readReplitPrompt( + context.siteDir, + folderSection, + slug, + ); + if (replitPrompt) { + replitPromptsBySlug[slug] = replitPrompt; + } } else { const filePath = resolve( context.siteDir, @@ -218,6 +228,7 @@ export default function contentEntriesPlugin( slugs: publishedSlugs, rawMarkdownBySlug, sectionsBySlug, + replitPromptsBySlug, }); for (const slug of publishedSlugs) { diff --git a/plugins/cookbooks.ts b/plugins/cookbooks.ts index 92581357..f2d8c489 100644 --- a/plugins/cookbooks.ts +++ b/plugins/cookbooks.ts @@ -3,6 +3,7 @@ import { getCookbookSlugs, readCookbookGoal, readCookbookIntro, + readReplitPrompt, } from "../src/lib/content-markdown"; import { cookbooks } from "../src/lib/recipes/recipes"; @@ -11,6 +12,8 @@ type CookbooksGlobalData = { goalsBySlug: Record; /** @deprecated Use goalsBySlug. Kept for backward compat during transition. */ introsBySlug: Record; + /** Raw `content/cookbooks//replit-prompt.md` bodies keyed by cookbook id. */ + replitPromptsBySlug: Record; }; function assertCookbookSlugParity(contentSlugs: string[]): void { @@ -32,6 +35,7 @@ export default function cookbooksPlugin(context: LoadContext): Plugin { const goalsBySlug: Record = {}; const introsBySlug: Record = {}; + const replitPromptsBySlug: Record = {}; for (const slug of contentSlugs) { const goal = readCookbookGoal(context.siteDir, slug); const intro = readCookbookIntro(context.siteDir, slug); @@ -40,11 +44,20 @@ export default function cookbooksPlugin(context: LoadContext): Plugin { goalsBySlug[slug] = text; introsBySlug[slug] = text; } + const replitPrompt = readReplitPrompt( + context.siteDir, + "cookbooks", + slug, + ); + if (replitPrompt) { + replitPromptsBySlug[slug] = replitPrompt; + } } actions.setGlobalData({ goalsBySlug, introsBySlug, + replitPromptsBySlug, } satisfies CookbooksGlobalData); }, }; diff --git a/scripts/validate-content.mjs b/scripts/validate-content.mjs index fdce1de5..6f39943d 100644 --- a/scripts/validate-content.mjs +++ b/scripts/validate-content.mjs @@ -11,12 +11,20 @@ if (!existsSync(resolve(ROOT, "content"))) { process.exit(1); } -const RESOURCE_ALLOWED_FILES = new Set(["goal.md", "prerequisites.md"]); +const RESOURCE_ALLOWED_FILES = new Set([ + "goal.md", + "prerequisites.md", + "replit-prompt.md", +]); /** A folder must have at least one of these to be published. */ const RESOURCE_REQUIRED_FILES = ["goal.md"]; const RESOURCE_SECTIONS = /** @type {const} */ (["recipes", "examples"]); -const COOKBOOK_ALLOWED_FILES = new Set(["goal.md", "intro.md"]); +const COOKBOOK_ALLOWED_FILES = new Set([ + "goal.md", + "intro.md", + "replit-prompt.md", +]); /** @type {string[]} */ const errors = []; diff --git a/src/components/agent-usage-card.tsx b/src/components/agent-usage-card.tsx index 8ab2caa7..03efab10 100644 --- a/src/components/agent-usage-card.tsx +++ b/src/components/agent-usage-card.tsx @@ -4,10 +4,23 @@ import { CopyPromptButton, type CopyPromptButtonProps, } from "@/components/copy-prompt-button"; +import { OpenPromptInButton } from "@/components/open-prompt-in-button"; +import { useReplitPrompt } from "@/lib/use-raw-content-markdown"; -type AgentUsageCardProps = Omit; +type AgentUsageCardProps = Omit< + CopyPromptButtonProps, + "className" | "label" +> & { + /** Template slug used to look up the optional Replit prompt. */ + slug: string; +}; + +export function AgentUsageCard({ + slug, + ...copyPromptProps +}: AgentUsageCardProps): ReactNode { + const replitPrompt = useReplitPrompt(slug); -export function AgentUsageCard(props: AgentUsageCardProps): ReactNode { return (
@@ -32,12 +45,18 @@ export function AgentUsageCard(props: AgentUsageCardProps): ReactNode { what you want -
+
+
diff --git a/src/components/cookbooks/cookbook-detail.tsx b/src/components/cookbooks/cookbook-detail.tsx index 5937267f..1aee0e19 100644 --- a/src/components/cookbooks/cookbook-detail.tsx +++ b/src/components/cookbooks/cookbook-detail.tsx @@ -46,6 +46,7 @@ export function CookbookDetail({

-
+
-
+
) : ( -
+
) { + return createElement( + "svg", + { + role: "img", + viewBox: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + fill: "currentColor", + "aria-hidden": "true", + ...props, + }, + createElement("path", { d: REPLIT_PATH }), + ); +} diff --git a/src/components/open-prompt-in-button.tsx b/src/components/open-prompt-in-button.tsx new file mode 100644 index 00000000..1519ce4d --- /dev/null +++ b/src/components/open-prompt-in-button.tsx @@ -0,0 +1,67 @@ +import { ChevronDown } from "lucide-react"; +import { track } from "@vercel/analytics"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { getPromptTargets } from "@/lib/prompt-targets"; + +type OpenPromptInButtonProps = { + replitPrompt?: string; + slug: string; + title: string; + permalink: string; +}; + +export function OpenPromptInButton({ + replitPrompt, + slug, + title, + permalink, +}: OpenPromptInButtonProps) { + const targets = getPromptTargets({ replitPrompt }); + if (targets.length === 0) return null; + + return ( + + + + + + {targets.map((target) => { + const Icon = target.icon; + return ( + + + track("open_prompt_in", { + target: target.id, + slug, + title, + permalink, + }) + } + > + + {target.label} + + + ); + })} + + + ); +} diff --git a/src/components/templates/active-filters.tsx b/src/components/templates/active-filters.tsx index 4557f46a..f3eb0981 100644 --- a/src/components/templates/active-filters.tsx +++ b/src/components/templates/active-filters.tsx @@ -7,20 +7,40 @@ export function ActiveFilters({ onRemoveTag, selectedServices, onRemoveService, + replitOnly, + onRemoveReplitOnly, onClearAll, }: { activeTags: Set; onRemoveTag: (tag: string) => void; selectedServices: Set; onRemoveService: (service: Service) => void; + replitOnly: boolean; + onRemoveReplitOnly: () => void; onClearAll: () => void; }) { - const hasFilters = activeTags.size > 0 || selectedServices.size > 0; + const hasFilters = + activeTags.size > 0 || selectedServices.size > 0 || replitOnly; if (!hasFilters) return null; return (
+ {replitOnly && ( + + )} {[...selectedServices].map((service) => (
+
+

+ Build with +

+
+ +
+
); } diff --git a/src/lib/content-markdown.ts b/src/lib/content-markdown.ts index 8d7d686c..333d3ae3 100644 --- a/src/lib/content-markdown.ts +++ b/src/lib/content-markdown.ts @@ -107,6 +107,54 @@ export function readCookbookGoal( return readFileSync(filePath, "utf-8"); } +type ReplitPromptTier = "recipes" | "examples" | "cookbooks"; + +/** + * Reads `content///replit-prompt.md` if present, prepended with + * the shared `content/replit-shared/preamble.md` separated by `---`. This + * mirrors the "shared boilerplate + per-template body" composition that + * `composeAgentPrompt` uses for the Copy prompt feature: each per-template + * file holds only the unique task; universal routing, PAT fallback, + * permission handling, style, and out-of-scope rules live in the preamble. + * + * Replit prompts are an opt-in export target, not a content section, so + * they live next to `goal.md` but stay out of `ContentSections` / + * `readContentSections`. + * + * Composition order: + * + * --- + * + */ +export function readReplitPrompt( + rootDir: string, + tier: ReplitPromptTier, + slug: string, +): string | undefined { + const dir = + tier === "cookbooks" + ? cookbookDirectory(rootDir) + : markdownDirectory(rootDir, tier); + const perTemplatePath = resolve(dir, slug, "replit-prompt.md"); + if (!existsSync(perTemplatePath)) return undefined; + + const preamblePath = resolve( + rootDir, + "content", + "replit-shared", + "preamble.md", + ); + if (!existsSync(preamblePath)) { + throw new Error( + `Required shared file missing: ${preamblePath}. ` + + `Every replit-prompt.md composes against this preamble; restore the file or remove the per-template prompts.`, + ); + } + const preamble = readFileSync(preamblePath, "utf-8").trimEnd(); + const perTemplate = readFileSync(perTemplatePath, "utf-8").trimEnd(); + return `${preamble}\n\n---\n\n${perTemplate}\n`; +} + /** Reads all present section files; throws when goal.md is missing. */ export function readContentSections( rootDir: string, diff --git a/src/lib/prompt-targets.ts b/src/lib/prompt-targets.ts new file mode 100644 index 00000000..1f1ff171 --- /dev/null +++ b/src/lib/prompt-targets.ts @@ -0,0 +1,41 @@ +import type { ComponentType, SVGProps } from "react"; +import { compressToEncodedURIComponent } from "lz-string"; +import { ReplitIcon } from "@/components/icons/replit-icon"; + +type PromptTarget = { + id: string; + label: string; + icon: ComponentType>; + href: string; +}; + +/** + * Builds the "Open in Replit" URL per the Open in Replit protocol. + * `stack=Build` selects Agent (Build mode); without it Replit may silently + * fail to fill the prompt. See https://docs.replit.com/references/integrations/open-in-replit. + */ +function buildReplitUrl(prompt: string): string { + const encoded = compressToEncodedURIComponent(prompt); + // Action-named utm_content (rather than component-named) so analytics + // history stays continuous if the dropdown is ever relocated. + const utm = + "utm_source=devhub&utm_medium=docs&utm_campaign=run-on-replit&utm_content=open-prompt-in"; + return `https://replit.com/?stack=Build&prompt=${encoded}&referrer=devhub&${utm}`; +} + +export function getPromptTargets({ + replitPrompt, +}: { + replitPrompt?: string; +}): PromptTarget[] { + const targets: PromptTarget[] = []; + if (replitPrompt) { + targets.push({ + id: "replit", + label: "Replit", + icon: ReplitIcon, + href: buildReplitUrl(replitPrompt), + }); + } + return targets; +} diff --git a/src/lib/use-raw-content-markdown.ts b/src/lib/use-raw-content-markdown.ts index 14405b13..d798e561 100644 --- a/src/lib/use-raw-content-markdown.ts +++ b/src/lib/use-raw-content-markdown.ts @@ -7,6 +7,7 @@ type ContentEntriesGlobalData = { slugs: string[]; rawMarkdownBySlug: Record; sectionsBySlug: Record; + replitPromptsBySlug: Record; }; export function useRawRecipeMarkdown(slug: string): string | undefined { @@ -36,6 +37,7 @@ export function useRawSolutionMarkdown(slug: string): string | undefined { type CookbooksGlobalData = { goalsBySlug: Record; introsBySlug: Record; + replitPromptsBySlug: Record; }; export function useCookbookGoal(slug: string): string | undefined { @@ -52,3 +54,58 @@ export function useExampleSections(slug: string): ContentSections | undefined { ) as ContentEntriesGlobalData; return data.sectionsBySlug[slug]; } + +/** + * Returns the raw `replit-prompt.md` body for a template, regardless of + * tier. Aggregates across the three plugin sources so detail pages don't + * have to know whether the slug is an example, recipe, or cookbook. + */ +export function useReplitPrompt(slug: string): string | undefined { + const examples = usePluginData( + "docusaurus-plugin-content-entries", + "examples", + ) as ContentEntriesGlobalData; + const recipes = usePluginData( + "docusaurus-plugin-content-entries", + "recipes", + ) as ContentEntriesGlobalData; + const cookbooks = usePluginData( + "docusaurus-plugin-cookbooks", + ) as CookbooksGlobalData; + return ( + examples.replitPromptsBySlug?.[slug] ?? + recipes.replitPromptsBySlug?.[slug] ?? + cookbooks.replitPromptsBySlug?.[slug] + ); +} + +/** + * Set of every template slug that ships a `replit-prompt.md`. Powers the + * "Build with > Replit Apps" filter on /templates. Derived from plugin + * data so authors only need to drop a `replit-prompt.md` next to their + * `goal.md` — no separate registry flag to maintain. + */ +export function useReplitTemplateIds(): ReadonlySet { + const examples = usePluginData( + "docusaurus-plugin-content-entries", + "examples", + ) as ContentEntriesGlobalData; + const recipes = usePluginData( + "docusaurus-plugin-content-entries", + "recipes", + ) as ContentEntriesGlobalData; + const cookbooks = usePluginData( + "docusaurus-plugin-cookbooks", + ) as CookbooksGlobalData; + const ids = new Set(); + for (const slug of Object.keys(examples.replitPromptsBySlug ?? {})) { + ids.add(slug); + } + for (const slug of Object.keys(recipes.replitPromptsBySlug ?? {})) { + ids.add(slug); + } + for (const slug of Object.keys(cookbooks.replitPromptsBySlug ?? {})) { + ids.add(slug); + } + return ids; +} diff --git a/src/pages/templates/index.tsx b/src/pages/templates/index.tsx index 8ce6793b..cc650c50 100644 --- a/src/pages/templates/index.tsx +++ b/src/pages/templates/index.tsx @@ -29,6 +29,7 @@ import { type Service, } from "@/lib/recipes/recipes"; import { useFeatureFlags } from "@/lib/feature-flags"; +import { useReplitTemplateIds } from "@/lib/use-raw-content-markdown"; function OfficialTemplatesCallout(): ReactNode { return ( @@ -101,9 +102,11 @@ export default function TemplatesPage(): ReactNode { new Set(), ); const [activeTags, setActiveTags] = useState>(new Set()); + const [replitOnly, setReplitOnly] = useState(false); const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); const { showDrafts: includeDrafts } = useFeatureFlags(); + const replitTemplateIds = useReplitTemplateIds(); const ALL_ITEMS = useMemo( () => buildTemplateItems(includeDrafts), @@ -112,14 +115,22 @@ export default function TemplatesPage(): ReactNode { const filteredItems = useMemo( () => - ALL_ITEMS.filter((item) => - matchesTemplateFilter(item.data, { + ALL_ITEMS.filter((item) => { + if (replitOnly && !replitTemplateIds.has(item.data.id)) return false; + return matchesTemplateFilter(item.data, { searchQuery, selectedServices, activeTags, - }), - ), - [searchQuery, selectedServices, activeTags, ALL_ITEMS], + }); + }), + [ + searchQuery, + selectedServices, + activeTags, + replitOnly, + replitTemplateIds, + ALL_ITEMS, + ], ); const handleToggleService = useCallback((service: Service) => { @@ -139,18 +150,26 @@ export default function TemplatesPage(): ReactNode { }); }, []); + const handleToggleReplitOnly = useCallback(() => { + setReplitOnly((prev) => !prev); + }, []); + const handleClearAllFilters = useCallback(() => { setSelectedServices(new Set()); setActiveTags(new Set()); setSearchQuery(""); + setReplitOnly(false); }, []); - const hasActiveFilters = activeTags.size > 0 || selectedServices.size > 0; + const hasActiveFilters = + activeTags.size > 0 || selectedServices.size > 0 || replitOnly; const filtersSidebar = ( ); @@ -199,7 +218,9 @@ export default function TemplatesPage(): ReactNode { Filters {hasActiveFilters && ( - {selectedServices.size + activeTags.size} + {selectedServices.size + + activeTags.size + + (replitOnly ? 1 : 0)} )} @@ -216,6 +237,8 @@ export default function TemplatesPage(): ReactNode { onRemoveTag={handleRemoveTag} selectedServices={selectedServices} onRemoveService={handleToggleService} + replitOnly={replitOnly} + onRemoveReplitOnly={handleToggleReplitOnly} onClearAll={handleClearAllFilters} />
diff --git a/tests/e2e/open-prompt-in.spec.ts b/tests/e2e/open-prompt-in.spec.ts new file mode 100644 index 00000000..54556bc2 --- /dev/null +++ b/tests/e2e/open-prompt-in.spec.ts @@ -0,0 +1,137 @@ +import { test, expect, type Page } from "@playwright/test"; +import { decompressFromEncodedURIComponent } from "lz-string"; +import { readReplitPrompt } from "../../src/lib/content-markdown"; + +// Playwright is run from the repo root (per package.json scripts and CI). +const REPO_ROOT = process.cwd(); + +type VaEvent = [string, { name: string; data: Record }]; +type WindowWithSpy = Window & { __vaEvents?: VaEvent[] }; + +/** + * Spies on `window.va` (the global function `@vercel/analytics` uses under + * the hood) so `track(...)` calls land in `window.__vaEvents`. Uses a + * configurable property so the analytics SDK can still assign its own + * implementation later without overwriting our spy. + */ +function setupAnalyticsSpy(page: Page) { + return page.addInitScript(() => { + const events: VaEvent[] = []; + (window as WindowWithSpy).__vaEvents = events; + let realVa: ((...args: unknown[]) => void) | undefined; + Object.defineProperty(window, "va", { + configurable: true, + get() { + return (...args: unknown[]) => { + events.push(args as VaEvent); + if (realVa) realVa(...args); + }; + }, + set(fn: (...args: unknown[]) => void) { + realVa = fn; + }, + }); + }); +} + +function getAnalyticsEvents(page: Page): Promise { + return page.evaluate(() => (window as WindowWithSpy).__vaEvents ?? []); +} + +test.describe("Open prompt in dropdown", () => { + test("renders on a Replit-enabled template; the Replit menu item links to a stack=Build URL that decodes back to the source file", async ({ + page, + }) => { + await page.goto("/templates/saas-tracker"); + + const trigger = page.getByRole("button", { name: "Open prompt in" }); + await expect(trigger).toBeVisible(); + await trigger.click(); + + const item = page.getByRole("menuitem", { name: "Replit" }); + await expect(item).toBeVisible(); + + const href = await item.getAttribute("href"); + expect(href, "Replit menu item should be a real anchor").toBeTruthy(); + + const url = new URL(href!); + expect(url.origin + url.pathname).toBe("https://replit.com/"); + // Replit's Open in Replit protocol requires stack=Build for Agent mode. + // Without it Replit may silently fail to fill the prompt. + expect(url.searchParams.get("stack")).toBe("Build"); + expect(url.searchParams.get("referrer")).toBe("devhub"); + expect(url.searchParams.get("utm_source")).toBe("devhub"); + + const encoded = url.searchParams.get("prompt"); + expect(encoded).toBeTruthy(); + + // End-to-end roundtrip: the prompt Replit Agent will see after decoding + // must equal the COMPOSED replit-prompt (shared preamble + --- + + // per-template task) byte-for-byte. Catches any future regression in + // lz-string encoding, the plugin pipeline, the useReplitPrompt + // aggregator, the buildReplitUrl helper, or the preamble composition. + const decoded = decompressFromEncodedURIComponent(encoded!); + const composed = readReplitPrompt(REPO_ROOT, "examples", "saas-tracker"); + expect( + composed, + "saas-tracker should ship a replit-prompt.md", + ).toBeTruthy(); + expect(decoded).toBe(composed); + }); + + test("does not render on a template without a replit-prompt.md", async ({ + page, + }) => { + await page.goto("/templates/set-up-your-local-dev-environment"); + await expect( + page.getByRole("button", { name: "Copy prompt" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open prompt in" }), + ).toHaveCount(0); + }); + + test("clicking the Replit menu item fires an open_prompt_in analytics event with the expected payload", async ({ + page, + }) => { + await setupAnalyticsSpy(page); + await page.goto("/templates/saas-tracker"); + + await page.getByRole("button", { name: "Open prompt in" }).click(); + const item = page.getByRole("menuitem", { name: "Replit" }); + await expect(item).toBeVisible(); + + // Block the actual navigation so the test stays on the page after click. + // The track() call runs before the listener resolves, so blocking nav + // doesn't suppress the event. + await page.evaluate(() => { + document + .querySelectorAll( + "[data-slot=dropdown-menu-content] a", + ) + .forEach((a) => { + a.addEventListener("click", (e) => e.preventDefault(), { + once: true, + }); + }); + }); + + await item.click(); + + const events = await getAnalyticsEvents(page); + const openEvent = events.find( + ([action, payload]) => + action === "event" && payload?.name === "open_prompt_in", + ); + expect( + openEvent, + "open_prompt_in event should have been recorded", + ).toBeTruthy(); + expect(openEvent![1].data).toEqual({ + target: "replit", + slug: "saas-tracker", + title: "SaaS Subscription Tracker", + permalink: "/templates/saas-tracker", + }); + }); +}); diff --git a/tests/e2e/resources-filters.spec.ts b/tests/e2e/resources-filters.spec.ts index 6d62fe07..e987c9d2 100644 --- a/tests/e2e/resources-filters.spec.ts +++ b/tests/e2e/resources-filters.spec.ts @@ -151,3 +151,43 @@ test.describe("templates page clear all filters", () => { await expect(page.getByRole("button", { name: "Clear all" })).toBeHidden(); }); }); + +test.describe("templates page Build-with Replit filter", () => { + test("checking 'Replit' narrows the grid to templates with a replit prompt and shows a removable chip", async ({ + page, + }) => { + await page.goto("/templates"); + await expect(page.getByText(TOTAL_TEMPLATES)).toBeVisible(); + + await page.getByRole("checkbox", { name: "Replit", exact: true }).check(); + + // saas-tracker ships a replit-prompt.md, so it should still be visible. + await expect( + page.locator('a[href="/templates/saas-tracker"]'), + ).toBeVisible(); + // set-up-your-local-dev-environment does NOT ship one, so it should hide. + await expect( + page.locator('a[href="/templates/set-up-your-local-dev-environment"]'), + ).toBeHidden(); + + // The active-filters chip should appear and clicking it should turn the + // filter back off. + const chip = page.getByRole("button", { name: /^Replit$/ }); + await expect(chip).toBeVisible(); + await chip.click(); + await expect(page.getByText(TOTAL_TEMPLATES)).toBeVisible(); + }); + + test("Build-with Replit filter participates in 'Clear all'", async ({ + page, + }) => { + await page.goto("/templates"); + + await page.getByRole("checkbox", { name: "Replit", exact: true }).check(); + await expect(page.getByRole("button", { name: "Clear all" })).toBeVisible(); + + await page.getByRole("button", { name: "Clear all" }).click(); + await expect(page.getByText(TOTAL_TEMPLATES)).toBeVisible(); + await expect(page.getByRole("button", { name: "Clear all" })).toBeHidden(); + }); +}); diff --git a/tests/prompt-targets.test.ts b/tests/prompt-targets.test.ts new file mode 100644 index 00000000..dfd4c29e --- /dev/null +++ b/tests/prompt-targets.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { decompressFromEncodedURIComponent } from "lz-string"; +import { getPromptTargets } from "../src/lib/prompt-targets"; + +describe("getPromptTargets", () => { + test("returns an empty array when no replitPrompt is provided", () => { + expect(getPromptTargets({})).toEqual([]); + expect(getPromptTargets({ replitPrompt: undefined })).toEqual([]); + expect(getPromptTargets({ replitPrompt: "" })).toEqual([]); + }); + + test("returns a single Replit target when replitPrompt is provided", () => { + const targets = getPromptTargets({ replitPrompt: "Hello Replit" }); + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ id: "replit", label: "Replit" }); + expect(targets[0].icon).toBeDefined(); + expect(targets[0].href).toMatch(/^https:\/\/replit\.com\/\?/); + }); + + test("Replit URL includes the required stack=Build parameter", () => { + // The Open in Replit protocol requires stack=Build for Agent (Build) mode. + // Without it Replit may silently fail to fill the prompt. See + // https://docs.replit.com/references/integrations/open-in-replit. + const targets = getPromptTargets({ replitPrompt: "Build me an app" }); + const url = new URL(targets[0].href); + expect(url.searchParams.get("stack")).toBe("Build"); + }); + + test("Replit URL includes referrer and full UTM attribution", () => { + const targets = getPromptTargets({ replitPrompt: "x" }); + const url = new URL(targets[0].href); + expect(url.searchParams.get("referrer")).toBe("devhub"); + expect(url.searchParams.get("utm_source")).toBe("devhub"); + expect(url.searchParams.get("utm_medium")).toBe("docs"); + expect(url.searchParams.get("utm_campaign")).toBe("run-on-replit"); + expect(url.searchParams.get("utm_content")).toBeTruthy(); + }); + + test("the encoded prompt param losslessly roundtrips to the original input", () => { + // Full pipeline: prompt text → lz-string encode → URL param → Replit's + // decode must equal the original. If this breaks, Replit Agent sees + // garbage instead of the template's instructions. + const original = [ + "# Build a Databricks app on Replit", + "", + "Steps:", + "1. Configure Replit Secrets `DATABRICKS_HOST` and `DATABRICKS_TOKEN`.", + "2. Run `SELECT current_user()` to verify the warehouse.", + "", + "Use the Databricks palette: #FF3621, #0B2026, #EEEDE9, #F9F7F4.", + "", + 'Ask: "Not sure — help me decide".', + ].join("\n"); + const targets = getPromptTargets({ replitPrompt: original }); + const encoded = new URL(targets[0].href).searchParams.get("prompt"); + expect(encoded).toBeTruthy(); + expect(decompressFromEncodedURIComponent(encoded!)).toBe(original); + }); +}); diff --git a/tests/replit-prompt-composition.test.ts b/tests/replit-prompt-composition.test.ts new file mode 100644 index 00000000..6aa205d0 --- /dev/null +++ b/tests/replit-prompt-composition.test.ts @@ -0,0 +1,118 @@ +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + unlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { readReplitPrompt } from "../src/lib/content-markdown"; + +/** + * Seeds a tempdir with a tiny content/ tree and exercises readReplitPrompt's + * preamble + --- + per-template composition contract. + */ +function seedFixture(root: string, files: Record): void { + for (const [relativePath, contents] of Object.entries(files)) { + const filePath = join(root, relativePath); + mkdirSync(join(filePath, ".."), { recursive: true }); + writeFileSync(filePath, contents, "utf-8"); + } +} + +const PREAMBLE = "shared preamble body"; + +describe("readReplitPrompt composition", () => { + let workDir: string; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "devhub-replit-compose-")); + seedFixture(workDir, { + "content/replit-shared/preamble.md": PREAMBLE, + }); + }); + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }); + }); + + test("returns undefined when the per-template file is missing", () => { + expect(readReplitPrompt(workDir, "examples", "missing")).toBe(undefined); + }); + + test("composes preamble + --- + per-template task in fixed order", () => { + seedFixture(workDir, { + "content/examples/demo/replit-prompt.md": "# Task\n\nDo the thing.", + }); + + expect(readReplitPrompt(workDir, "examples", "demo")).toBe( + "shared preamble body\n\n---\n\n# Task\n\nDo the thing.\n", + ); + }); + + test("starts with the preamble's opening line", () => { + seedFixture(workDir, { + "content/examples/demo/replit-prompt.md": "task", + }); + const composed = readReplitPrompt(workDir, "examples", "demo"); + expect(composed?.startsWith(PREAMBLE)).toBe(true); + }); + + test("contains exactly one '---' separator between preamble and task", () => { + seedFixture(workDir, { + "content/examples/demo/replit-prompt.md": "task line one\ntask line two", + }); + const composed = readReplitPrompt(workDir, "examples", "demo"); + expect(composed).toBeTruthy(); + expect(composed!.match(/^---$/gm)?.length).toBe(1); + }); + + test("ends with the per-template task body followed by a trailing newline", () => { + seedFixture(workDir, { + "content/recipes/demo/replit-prompt.md": "## Task\n\nFinal line of task.", + }); + const composed = readReplitPrompt(workDir, "recipes", "demo"); + expect(composed?.endsWith("Final line of task.\n")).toBe(true); + }); + + test("works across all three tiers (examples, recipes, cookbooks)", () => { + seedFixture(workDir, { + "content/examples/ex/replit-prompt.md": "example task", + "content/recipes/rc/replit-prompt.md": "recipe task", + "content/cookbooks/ck/replit-prompt.md": "cookbook task", + }); + + expect(readReplitPrompt(workDir, "examples", "ex")).toContain( + "example task", + ); + expect(readReplitPrompt(workDir, "recipes", "rc")).toContain("recipe task"); + expect(readReplitPrompt(workDir, "cookbooks", "ck")).toContain( + "cookbook task", + ); + }); + + test("trims trailing whitespace from both files before joining", () => { + seedFixture(workDir, { + "content/replit-shared/preamble.md": "preamble\n\n\n", + "content/examples/demo/replit-prompt.md": "task\n\n\n\n", + }); + expect(readReplitPrompt(workDir, "examples", "demo")).toBe( + "preamble\n\n---\n\ntask\n", + ); + }); + + test("throws a clear error when the shared preamble is missing", () => { + // Per-template file exists, but preamble was deleted — should fail loud + // with a useful message rather than an opaque ENOENT. + unlinkSync(join(workDir, "content/replit-shared/preamble.md")); + seedFixture(workDir, { + "content/examples/orphan/replit-prompt.md": "task", + }); + + expect(() => readReplitPrompt(workDir, "examples", "orphan")).toThrow( + /Required shared file missing.*preamble\.md/, + ); + }); +}); diff --git a/tests/validate-content.test.ts b/tests/validate-content.test.ts index 153f4ef0..3eec3853 100644 --- a/tests/validate-content.test.ts +++ b/tests/validate-content.test.ts @@ -120,6 +120,21 @@ describe("validate-content script", () => { expect(result.stderr).toContain("not an allowed filename"); }); + test("accepts replit-prompt.md alongside goal.md in a resource folder", () => { + seedFixture(workDir, { + "content/recipes/with-replit/goal.md": "Build it.\n", + "content/recipes/with-replit/replit-prompt.md": + "You are Replit Agent. Build...\n", + "content/examples/also-replit/goal.md": "Build it.\n", + "content/examples/also-replit/replit-prompt.md": + "You are Replit Agent. Build...\n", + }); + + const result = runValidator(workDir); + expect(result.status).toBe(0); + expect(result.stdout).toContain("validation passed"); + }); + test("accepts content/cookbooks//intro.md", () => { seedFixture(workDir, { "content/recipes/r/goal.md": "Build it.\n", @@ -142,6 +157,20 @@ describe("validate-content script", () => { expect(result.stderr).toContain("not a directory"); }); + test("accepts replit-prompt.md alongside intro.md in a cookbook folder", () => { + seedFixture(workDir, { + "content/recipes/r/goal.md": "Build it.\n", + "content/examples/e/goal.md": "Build it.\n", + "content/cookbooks/with-replit/intro.md": "## Intro\n", + "content/cookbooks/with-replit/replit-prompt.md": + "You are Replit Agent. Build...\n", + }); + + const result = runValidator(workDir); + expect(result.status).toBe(0); + expect(result.stdout).toContain("validation passed"); + }); + test("fails when a cookbook folder has a disallowed filename", () => { seedFixture(workDir, { "content/cookbooks/my-cookbook/intro.md": "## Intro\n",