Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 0 additions & 3 deletions .env

This file was deleted.

2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# vendored third-party builds: collapse in PR diffs
assets/js/vendor/** linguist-generated=true linguist-vendored=true
32 changes: 10 additions & 22 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,42 +16,30 @@ permissions:
contents: read
pull-requests: write

jobs:
test:
name: Run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2

- run: bun install --frozen-lockfile
env:
HUGO_VERSION: 0.147.8

jobs:
build:
name: Build site
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2

- run: bun install --frozen-lockfile

- name: Cache imagetools directory
uses: actions/cache@v4
with:
path: ./node_modules/.cache/imagetools
key: imagetools-${{ hashFiles('**/*.png', '**/*.jpg', '**/*.jpeg', '**/*.gif') }}
restore-keys: |
imagetools-
- name: Install Hugo
run: |
curl -sL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz" | tar xz hugo
sudo mv hugo /usr/local/bin/hugo

- if: github.event_name != 'pull_request' && github.ref == 'refs/heads/master'
name: Build (production)
run: bun run build
run: hugo --gc --cleanDestinationDir
env:
VITE_SHOPIFY_STOREFRONT_API_TOKEN: ${{ secrets.VITE_SHOPIFY_STOREFRONT_API_TOKEN }}
HUGO_PARAMS_SHOPIFY_STOREFRONTAPITOKEN: ${{ secrets.VITE_SHOPIFY_STOREFRONT_API_TOKEN }}

- if: github.event_name == 'pull_request' || github.ref != 'refs/heads/master'
name: Build (development)
run: bun run build
run: hugo --gc --cleanDestinationDir

- name: Upload built project
uses: actions/upload-artifact@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/compatibility.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
run: |
CHANGED_FILES="$(git diff --name-only)"
echo "$CHANGED_FILES"
if [ "$(echo "$CHANGED_FILES" | wc -l)" -eq 1 ] && grep -qx 'src/lib/compatibility-meta.json' <<< "$CHANGED_FILES"; then
if [ "$(echo "$CHANGED_FILES" | wc -l)" -eq 1 ] && grep -qx 'data/compatibility_meta.json' <<< "$CHANGED_FILES"; then
echo "skip_pr=true" >> "$GITHUB_OUTPUT"
fi
- name: Create Pull Request
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/spellcheck.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ jobs:
- uses: codespell-project/actions-codespell@bcf481f4d5cce7b92b65f05aebe8f552d4f1442c
with:
ignore_words_list: hda,som,tge
path: src
path: layouts content data assets/js scripts
skip: '*/vendor/*'
11 changes: 2 additions & 9 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,13 @@ node_modules
!.vscode/extensions.json

# Output
.output
/.svelte-kit
/build
/resources
.hugo_build.lock
.firebase
firebase-debug.log

# OS
.DS_Store
Thumbs.db

# Env
.env.local
.env.production

# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
1 change: 0 additions & 1 deletion .npmrc

This file was deleted.

2 changes: 1 addition & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"recommendations": [
"svelte.svelte-vscode"
"budparr.language-hugo-vscode"
]
}
28 changes: 20 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# https://comma.ai

Built on [Svelte 4](https://svelte.dev).
Built with [Hugo](https://gohugo.io) (extended edition) — no JS framework, no node build step.

## Develop

Expand All @@ -10,15 +10,27 @@ Built on [Svelte 4](https://svelte.dev).

Other commands to know:
```bash
# install dependencies
bun install
# start dev server (http://localhost:1313)
hugo server

# start dev server
bun run dev

# production build
bun run build
# production build (flat .html files into build/, served by firebase cleanUrls)
hugo --gc --cleanDestinationDir
firebase serve # or `bun run preview` without firebase login
```

use `./encode.sh <video_file.mp4>` to update the hero video

## Layout

- `content/` — every page lives here as ONE file: front matter (title, `js:` bundle, head extras) + the full page markup inside `{{< body.inline >}}`. Exception: the 17 `/shop` product pages are front-matter-only stubs stamped from `data/products.json` by the shared `layouts/shop/single.html` template.
- `layouts/` — the shell (`baseof`, header/footer/cart partials, the shared product template); page markup does NOT live here
- `assets/js/` — vanilla JS; `main.js` is the global shell (cart, menus), `pages/<name>.js` are per-page entries (each starts with `import '../main.js'`), bundled by Hugo's esbuild
- `assets/css/site.css` — the stylesheet (scoped class names `svelte-*` are kept from the old build; HTML and CSS must agree on them)
- `data/` — products, vehicles (regenerated nightly by the compatibility workflow), harnesses, faq
- `static/img/` — images; each jpg/jpeg/png/gif has committed `.avif`/`.webp` siblings rendered by `partials/picture.html`. After adding images, run `bun install && bun run images` (uses sharp) and commit the variants.

Shopify Storefront credentials live in `hugo.toml` under `params.shopify` (dev store).
Production overrides via env: `HUGO_PARAMS_SHOPIFY_STOREFRONTAPITOKEN` (set in CI).
The build fetches the blog feed (header menu, /openpilot) and Shopify product data
(/shop/*) at build time and fails hard if either is unreachable — same behavior as
the old SvelteKit prerender.
52 changes: 52 additions & 0 deletions assets/css/site.css

Large diffs are not rendered by default.

File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
134 changes: 134 additions & 0 deletions assets/js/lib/cart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Cross-page cart API (port of src/store.js).
*
* Pages must talk to the cart ONLY through this module:
*
* import { addToCart, openCart, refreshCart } from '../lib/cart.js';
*
* addToCart(variantId, additionalProductIds = [], note = "")
* Adds the variant (qty 1 each, plus optional extra product ids and cart
* note) to the Shopify cart, reloads it and opens the cart drawer.
* openCart()
* Reloads the cart, then opens the drawer.
* refreshCart()
* Reloads the cart from Shopify into the stores below (the old
* store.js `loadCart`). Errors are caught and logged.
*
* State is exposed as tiny Svelte-compatible writable stores (set/update/
* subscribe — subscribe fires immediately with the current value):
* cart, search — dead code carried over from store.js
* showCart — drawer visibility (main.js renders the drawer)
* cartId, checkoutUrl, cartCreatedAt, cartTotalQuantity
* — initialized from localStorage and written back on
* EVERY change via `window.localStorage.<key> = v`,
* which stringifies: undefined becomes the literal
* string "undefined" (load-bearing — shopify.js
* treats "undefined"/"null" as "create new cart")
* cartItems, cartDiscount, cartSubtotal — current cart contents
* selectedCar — localStorage-synced (setItem when truthy,
* removeItem when falsy); used by other pages
*
* localStorage keys (exact): cartId, checkoutUrl, cartCreatedAt,
* cartTotalQuantity, selectedCar.
*/
import {
addToCart as requestAddToCart,
loadCart as requestLoadCart,
} from './shopify.js';

// --- minimal svelte/store replacement -------------------------------------

// Svelte's safe_not_equal: objects/functions always notify, primitives only
// when changed (NaN-safe).
const safeNotEqual = (a, b) =>
a != a ? b == b : a !== b || (a && typeof a === 'object') || typeof a === 'function';

export function writable(value) {
const subscribers = new Set();
return {
set(newValue) {
if (safeNotEqual(value, newValue)) {
value = newValue;
subscribers.forEach((fn) => fn(value));
}
},
update(fn) {
this.set(fn(value));
},
subscribe(fn) {
subscribers.add(fn);
fn(value); // svelte stores fire immediately on subscribe
return () => subscribers.delete(fn);
},
get() {
return value;
},
};
}

export const get = (store) => store.get();

// --- stores (src/store.js) -------------------------------------------------

export const cart = writable([]);
export const search = writable('');
export const showCart = writable(false);

export const cartId = writable(window.localStorage.getItem('cartId'));
export const checkoutUrl = writable(window.localStorage.getItem('checkoutUrl'));
export const cartCreatedAt = writable(window.localStorage.getItem('cartCreatedAt'));
export const cartTotalQuantity = writable(window.localStorage.getItem('cartTotalQuantity'));

export const cartItems = writable([]);
export const cartDiscount = writable({});
export const cartSubtotal = writable({});
export const selectedCar = writable(localStorage.getItem('selectedCar') || '');

selectedCar.subscribe((value) => {
if (value) localStorage.setItem('selectedCar', value);
else localStorage.removeItem('selectedCar');
});

// Write-through on every change. Property assignment stringifies the value,
// so undefined/null become the strings "undefined"/"null" — keep as-is.
cartId.subscribe((value) => (window.localStorage.cartId = value));
checkoutUrl.subscribe((value) => (window.localStorage.checkoutUrl = value));
cartCreatedAt.subscribe((value) => (window.localStorage.cartCreatedAt = value));
cartTotalQuantity.subscribe((value) => (window.localStorage.cartTotalQuantity = value));

// --- cart actions (src/store.js loadCart/addToCart) -------------------------

// src/store.js `loadCart` — exported as refreshCart (cross-page contract name).
export const refreshCart = async () => {
try {
const shopifyResponse = await requestLoadCart();
cartItems.set(shopifyResponse?.body?.data?.cart?.lines?.edges);
cartDiscount.set(getTotalDiscount(shopifyResponse?.body?.data?.cart?.discountAllocations));
cartSubtotal.set(shopifyResponse?.body?.data?.cart?.cost?.subtotalAmount);
cartTotalQuantity.set(shopifyResponse.body?.data?.cart?.totalQuantity);
} catch (error) {
console.error(error);
}
};

export const addToCart = async (itemId, additionalProductIds = [], note = "") => {
await requestAddToCart({ cartId: get(cartId), variantId: itemId, additionalProductIds, note });
await refreshCart();
showCart.set(true);
};

// src/routes/+layout.svelte `openCart`
export const openCart = async () => {
await refreshCart();
showCart.set(true);
};

export const getTotalDiscount = (discountAllocations) => {
if (!discountAllocations || discountAllocations.length === 0) return null;

const discountAmount = discountAllocations.reduce((totalAmount, allocation) => {
return totalAmount + Number(allocation.discountedAmount.amount);
}, 0);

return { amount: discountAmount, currencyCode: discountAllocations[0].discountedAmount.currencyCode };
};
41 changes: 41 additions & 0 deletions assets/js/lib/click-outside.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Verbatim port of src/lib/utils/clickOutside.js (Svelte action).
*
* Attaches document-level CAPTURE listeners for `click` (dispatches a
* `clickOutside` CustomEvent on the node when the click lands outside it) and
* `keydown` (dispatches on Escape). NOTE: destroy() forgets to remove the
* keydown listener — that leak is part of the original and is kept on purpose
* (irrelevant under full page loads). The CustomEvent is constructed with the
* node as the init object, exactly like the original.
*
* Usage:
* const action = clickOutside(node);
* node.addEventListener('clickOutside', handler);
* // action.destroy() when tearing down (never needed on these pages)
*/
export function clickOutside(node) {
const handleClick = event => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
node.dispatchEvent(
new CustomEvent('clickOutside', node)
)
}
}

const handleKeydown = event => {
if (event.key === 'Escape') {
node.dispatchEvent(
new CustomEvent('clickOutside', node)
)
}
}

document.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleKeydown, true);

return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
}
}
8 changes: 7 additions & 1 deletion src/lib/utils/currency.js → assets/js/lib/currency.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
const getCurrencySymbol = (currencyCode) => {
/**
* Currency formatting (port of src/lib/utils/currency.js).
* getCurrencySymbol("USD") -> "$"
* formatCurrency({ currencyCode, amount }, decimals = 2) -> "$1250.00"
* (no thousands separators, matching the old site)
*/
export const getCurrencySymbol = (currencyCode) => {
return (0).toLocaleString("en-US", {
style: "currency",
currency: currencyCode,
Expand Down
31 changes: 31 additions & 0 deletions assets/js/lib/faq.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Shared FAQ hash-sync, porting Faq.svelte's `checked={id === activehash}` reactivity.
// - On load: check the input whose id matches location.hash (native scroll already happened).
// - On hashchange: uncheck only the input previously opened BY hash (manually-opened stay open),
// then check the new target.
// - Every click on a FAQ input rewrites the hash via replaceState — on open AND close clicks —
// without firing hashchange (matches Svelte's router-bypassing goto/replaceState).
// Scoped to `.questions .tab > input` so non-FAQ accordions (e.g. /support top section) are untouched.
export function initFaq(root = document) {
let hashOpened = null;

const openFromHash = () => {
const id = location.hash.slice(1);
if (hashOpened && hashOpened.id !== id) {
hashOpened.checked = false;
hashOpened = null;
}
if (!id) return;
const inp = root.querySelector(`.questions .tab > input[id="${CSS.escape(id)}"]`);
if (inp) {
inp.checked = true;
hashOpened = inp;
}
};

openFromHash();
window.addEventListener('hashchange', openFromHash);

root.querySelectorAll('.questions .tab > input').forEach((inp) =>
inp.addEventListener('click', () => history.replaceState(null, null, '#' + inp.id))
);
}
Loading
Loading