Monorepo for the DST network: the group's own brand site, each Dubai-focused vertical it operates, and the standalone experiments. Static Astro sites, deployed independently, sharing one design system.
A site is mostly defined by which of these it switches on. Each group is a
set of components in packages/ui that only make sense together — a site
takes the whole group or none of it.
- Feed — the news and events spine:
NewsBlock/NewsList/NewsArticle,EventsBlock/EventsList/EventArticle, and theFeedCardsthey all render through. Brings/go/outbound hops (Redirect) and.icscalendar files with it. Every site butmusical, which publishes runs rather than a feed. - Lead capture —
LeadForm,LeadTracking,Callout,Faq. The route from a reader to a qualified enquiry, on the verticals where DKEY executes. - District —
DistrictHub,ContentPage,VenuePage,VenueCard,DistrictRentForm, and theDistrictLayoutthat replacesBaseLayouton these sites. A neighbourhood covered block by block: venues, amenities, and what it costs to live there. - Media —
Photo,PhotoCredit,Lightbox, anddistrict/PhotoHero. Licensed photography with its credit attached; a site without pictures it may use does not switch this on. - Wayfinding —
Breadcrumbs,OnThisPage,Glance,MapEmbed,CountdownScript. For sites deep enough that a reader can get lost in them. - Portfolio —
VentureGrid. A grid of what the operator runs, with live/in-development status.
| App | Domain | What it is | Feature groups |
|---|---|---|---|
dst |
dst.llc | The group's brand site — the systems behind the ventures | Feed, Portfolio, Lead capture, Wayfinding |
llc |
llc.dst.llc | Starting a business in Dubai: mainland, free zone, offshore | Feed, Lead capture |
visas |
visas.dst.llc | UAE residency routes, employment through Golden Visa | Feed, Lead capture |
riviera |
riviera.dst.llc | Azizi Riviera, block by block | Feed, District |
mbr |
mbr.dst.llc | MBR City, block by block | Feed, District |
palmcentral |
palmcentral.dst.llc | Palm Central by Nakheel — pricing, payment plan, eligibility | Feed, Lead capture, Media |
eco |
eco.dst.llc | The eco portfolio: funded environmental initiatives | Feed, Portfolio, Lead capture, Media |
api |
api.dst.llc | Serverless endpoints. Not an Astro site — no UI at all | — |
fwf |
fwf.lol | Independent guide to one conference: Future World Forum Dubai | Feed, Lead capture, Media, Wayfinding |
musical |
musical.today | Musicals city by city — venues, runs, and who is actually selling | Lead capture, District, Media, Wayfinding, Portfolio |
nyc42 |
nyc42.lol | What's on in New York City | Feed |
ldn |
ldn.lol | What's on in central London | Feed |
lnd |
lnd.lol | Greater London, beyond the centre | Feed |
cmx |
cmx.lol | Qué hacer en la Ciudad de México | Feed |
mxo |
mxo.lol | La agenda de México | Feed |
sol2go |
sol2go.lol | Solana meetups, hackathons and the crypto calendar | Feed |
vien |
vien.lol | Was in Wien los ist — Wien und Österreich, auf Deutsch | Feed |
Two things the table does not show and that the code enforces:
- The five
.lolsites are independent. They carry no link to dst.llc and none to each other. They are year-long experiments with their own domains, sharing the code and nothing else.musical.todayis separate from the group in the same way, down to its own publisher in the structured data. - cmx and mxo publish in Spanish. They render
lang="es-MX"and pass a Spanish label set from their owncontent.tsinto the shared components (packages/ui/src/labels.ts). Everything else in the network takes the English defaults.
Every home page in the network ends with the same two blocks, in one of two orders. Which order is not a style choice — it answers what the reader came for:
- A — agenda.
EventsBlock, thenNewsBlock. For sites whose subject is dated:dst,riviera,mbr,fwf, and the five.lolcity sites. What is on soon outranks what was filed recently. - B — advisory.
NewsBlock, thenEventsBlock. For sites whose subject is a rule or a purchase:llc,visas,eco,palmcentral. A reader there wants what changed before they want a date.
Both blocks render their own <section class="container">. Call them bare —
wrapping one in another .container doubles the gutter and pushes the feed
in from the grid the rest of the page sits on.
npm install
npm run dev:nyc42 # dev:<app> for any app in the table
npm run build # every app
npm run build:nyc42 # build:<app> for oneTests run against the build output, so build first:
npm run test:html && npm run test:links && npm run test:schema && npm run test:images && npm run test:networkAnything touching packages/ui is a change to every site — check them all
visually, not just the one you were working on:
node tools/visual-check.mjs <url> --widths 390,944,1280 --scheme bothset -a && . ~/dst/.env && set +a # FAL_AI_API_KEY
node tools/covers.mjs --dry # what is missing, and what it costs
node tools/covers.mjs && node tools/images.mjsSpend is capped at a dollar a day by tools/fal-budget.mjs, which reserves
before each request and refuses the one that would cross the line — the run
stops there rather than carrying on with the cheaper half of a set. The
running total is tools/fal-spend.json, one line per day, committed. Raising
the ceiling means editing LIMIT and committing that, on purpose; there is
no environment variable for it.
npm run pipeline # llms.txt, build, dates, rebuild what moved
./tools/deploy.sh nyc42 # one site, or --allpipeline is the two generated files and the builds they need, in the one
order that works: llms.txt is written into public/ and has to exist
before the build that copies it, while lastmod.json is computed by walking
dist/ and so needs a build before it and another after. Done in that order
a ship costs two builds; done with llms.mjs last it costs three, which is
the easy mistake to make from a cold tree, because lastmod.mjs refuses to
run without dist/ and invites you to build first.
Neither build is the whole network if it does not have to be. The first
skips the sites nothing touched, comparing input hashes against
node_modules/.cache/dst-pipeline.json; the second skips the sites whose
dates did not move. What counts as an input is drawn from what the sources
actually read: a site's own packages/content/src/{events,news}/<app>.ts,
plus — for dst alone, the one page in the network that aggregates — every
site's. A change to packages/ui or to the shared content modules rebuilds
all seventeen. lastmod.json is deliberately not an input, because the
second pass already decides who needs rebuilding for it. DST_BUILD_ALL=1
ignores the cache.
Measured: nothing changed, 7s; one site's page, 10s; a content entry (that site and the hub), 10s; everything, 28s. The ceiling — all seventeen rebuilt in both passes — is about 50s on an idle machine, and two to three times that when it is paging.
Builds run four at a time, straight through node rather than through npm run build --workspace, which costs about half a second an app in npm's own
startup, with Astro's telemetry off. Four is the measured knee on this
machine: seventeen apps take 35.7s one at a time, 20.6s at four, and 25.2s
at eight, where the swap file grows half a gigabyte in a run and it pages
instead of building. Concurrency here is a memory budget, not a core count —
set DST_BUILD_LANES=1 when something else big is resident. See
tools/pipeline.mjs.
deploy.sh exists because a push rebuilds every app the commit touched, and
a packages/ change touches all fifteen — against a Hobby limit of 100
builds a day. Use --force when a build fails with no error of its own
right after "Restored build cache from previous deployment".
python3 tools/ga-report.py --dry-run # print it, send nothing
python3 tools/ga-report.py --period weekOne GA4 digest a day to Telegram, from a cron entry at 16:20. The period picks itself rather than living in the crontab: the first Friday of a month reports the month, any other Friday the week, every other day the last 24 hours. A monthly Friday does not also send a weekly one.
The daily one is read by dateHour and bucketed into the trailing 24 hours
against the 24 before them — a digest sent at 16:20 cannot compare a
part-day against a whole one without printing a minus sign every afternoon.
tools/ga.py remains the wide table for reading at a terminal.
-
Copy the shape of an existing app with the same feature groups —
nyc42for a plain feed site,rivierafor a district,llcfor a lead-capture vertical. -
Write its
src/site.config.ts: the site's name, its title suffix, its language, its publisher, and whether it names DST in the footer. That file is the whole of what a copied app must not inherit, which is why it is a file and not props inLayout.astro— the layout is copied, the config is written. A test rejects a layout that sets any of it itself. -
Add
dev:<name>/build:<name>to the rootpackage.jsonand include the app in the rootbuildscript. -
Give it an accent in
src/styles/theme.cssand a palette intools/covers.json— a site without one cannot generate covers. The accent is two palettes and nothing else::root { --accent-light: …; --accent-ink-light: …; --accent-tint-light: …; --accent-dark: …; --accent-ink-dark: …; --accent-tint-dark: …; }
Which one applies is
tokens.css's job, not the site's. Don't write a[data-theme]rule here — the switch has three states, the third is easy to get wrong, and a test rejects atheme.cssthat reimplements it. -
Add it to
SITESintools/deploy.sh, and to the table above. -
Cross-link it from
apps/dstand related verticals — unless it is a.lolexperiment, which links to nothing in the network.
See ~/mind/ai/dubai/seo/guidelines.md and the per-vertical concept docs
under ~/mind/ai/dubai/*.dst.llc.md for content and SEO direction — this
repo is code, not the source of truth for site strategy.
node tools/prices.mjs <tsv> reads what sellers advertise off their own
pages and prints it. It writes nothing: prices go into runs.ts by hand,
because a wrong one is the only error on this site a reader finds out about
at the till.
It trusts two sources unequally. A schema.org Offer in the seller's own
JSON-LD, on an event whose name matches the show, is written as-is — sellers
publish one per performance, so the figure taken is the minimum across them.
A "from £25" in the visible text is flagged for a human instead: on the first
pass three of nine such matches were a group discount ("£5 off"), a
membership fee ("Join now from £35") and a schools rate, none of them a price
anyone could buy a seat at.
Prices expire. PRICE_STALE_DAYS in apps/musical/src/rules.ts is 42 days;
past that the number leaves the page and the markup rather than ageing on
them, and priceValidUntil publishes the same date to search engines. A site
rebuilt weekly cannot promise a live price, so it promises a dated one.
Sites behind a bot check are reported as blocked. We do not try to get past one.