Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ jobs:
run: bun scripts/lint/no-duplicate-deps.ts
- name: Check package.json ordering
run: bun scripts/format/sort-package-json.ts --check
# TODO: remove continue-on-error once the existing typeof/cast backlog is cleared.
# Pre-push hook already blocks new violations — these report on the backlog.
- name: Custom lint rules (typeof guards, raw regex, process.env)
run: bun lint:custom
continue-on-error: true
# TODO: remove continue-on-error once the type-cast backlog (130) is cleared.
- name: Check unsafe type casts
run: bun check:casts:strict
continue-on-error: true
Expand Down
6 changes: 2 additions & 4 deletions apps/admin/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@ import { Input } from '@packrat/web-ui/components/input';
import { Label } from '@packrat/web-ui/components/label';
import { storeToken } from 'admin-app/lib/auth';
import { useCFAccessIdentity } from 'admin-app/lib/cfAccess';
import { adminEnv } from 'admin-app/lib/env';
import { Package, Shield } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';

const API_BASE = process.env.NEXT_PUBLIC_API_URL;
if (!API_BASE) {
throw new Error('NEXT_PUBLIC_API_URL must be set (root .env.local → PUBLIC_API_URL)');
}
const API_BASE = adminEnv.NEXT_PUBLIC_API_URL;

export default function LoginPage() {
const router = useRouter();
Expand Down
6 changes: 2 additions & 4 deletions apps/admin/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,9 @@

import { clearToken, getAuthHeader } from './auth';
import { getCFAccessJWT } from './cfAccess';
import { adminEnv } from './env';

const API_BASE = process.env.NEXT_PUBLIC_API_URL;
if (!API_BASE) {
throw new Error('NEXT_PUBLIC_API_URL must be set (root .env.local → PUBLIC_API_URL)');
}
const API_BASE = adminEnv.NEXT_PUBLIC_API_URL;

async function buildAuthHeaders(): Promise<Record<string, string>> {
const cfJwt = await getCFAccessJWT();
Expand Down
21 changes: 21 additions & 0 deletions apps/admin/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Admin app environment shim.
* Parses `process.env` once at module load using Zod and exports a typed result.
*
* Adding a new variable: declare it on `adminEnvSchema`, mark it
* `.optional()` unless every caller genuinely requires it.
*/

import { z } from 'zod';

const adminEnvSchema = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
});

export type AdminEnv = z.infer<typeof adminEnvSchema>;

/**
* Typed env parsed from `process.env` at module load. Throws a Zod
* validation error if any value fails its schema constraint.
*/
export const adminEnv = adminEnvSchema.parse(process.env);
17 changes: 12 additions & 5 deletions apps/expo/utils/format-ai-response.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
// ── Formatting regex constants ──
const BULLET_LINE_PATTERN = /^\s*[-*]\s+(.+)$/gm;
const SENTENCE_BOUNDARY_PATTERN = /([.?!])\s*(?=[A-Z])/g;
const BOLD_MARKDOWN_PATTERN = /\*\*(.+?)\*\*/g;
const ITALIC_MARKDOWN_PATTERN = /\*(.+?)\*/g;
const MARKDOWN_HEADER_PATTERN = /^#+\s+(.+)$/gm;

/**
* Formats AI responses to improve readability in the chat UI
* - Converts markdown lists to plain text with proper spacing
Expand All @@ -6,17 +13,17 @@
*/
export function formatAIResponse(text: string): string {
// Convert markdown lists to plain text with emoji bullets
let formatted = text.replace(/^\s*[-*]\s+(.+)$/gm, '• $1');
let formatted = text.replace(BULLET_LINE_PATTERN, '• $1');

// Add proper spacing after periods, question marks, and exclamation points
formatted = formatted.replace(/([.?!])\s*(?=[A-Z])/g, '$1\n\n');
formatted = formatted.replace(SENTENCE_BOUNDARY_PATTERN, '$1\n\n');

// Convert markdown emphasis to plain text
formatted = formatted.replace(/\*\*(.+?)\*\*/g, '$1');
formatted = formatted.replace(/\*(.+?)\*/g, '$1');
formatted = formatted.replace(BOLD_MARKDOWN_PATTERN, '$1');
formatted = formatted.replace(ITALIC_MARKDOWN_PATTERN, '$1');

// Handle markdown headers
formatted = formatted.replace(/^#+\s+(.+)$/gm, '$1');
formatted = formatted.replace(MARKDOWN_HEADER_PATTERN, '$1');

return formatted.trim();
}
11 changes: 6 additions & 5 deletions apps/guides/scripts/enhance-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import matter from 'gray-matter';
import path from 'path';
import { type ContentEnhancementOptions, enhanceGuideContent } from '../lib/enhanceGuideContent';

// ── Script regex constants ──
const TIMESTAMP_UNSAFE_CHARS = /[:.]/g;

Comment on lines +7 to +9
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIMESTAMP_SAFE_CHARS is named as if it matches characters that are safe to keep, but the pattern /[:.]/g matches characters that are typically unsafe in filenames and are being replaced. Renaming this to something like TIMESTAMP_UNSAFE_CHARS (or similar) would better reflect its purpose and align with the naming used elsewhere (e.g., TIMESTAMP_UNSAFE_CHARS in analytics).

Copilot uses AI. Check for mistakes.
// Configuration
const CONTENT_DIR = path.join(process.cwd(), 'content/posts');
const BACKUP_DIR = path.join(process.cwd(), 'content/backups');
Expand Down Expand Up @@ -41,7 +44,7 @@ function ensureBackupDir(): void {
*/
function createBackup(filePath: string): string {
const fileName = path.basename(filePath);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const timestamp = new Date().toISOString().replace(TIMESTAMP_UNSAFE_CHARS, '-');
const backupPath = path.join(BACKUP_DIR, `${timestamp}-${fileName}`);

fs.copyFileSync(filePath, backupPath);
Expand Down Expand Up @@ -107,10 +110,8 @@ function getContentFiles(pattern?: string): string[] {
.map((file) => path.join(CONTENT_DIR, file));

if (pattern) {
// Escape special regex characters to prevent regex injection
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(escapedPattern, 'i');
return files.filter((file) => regex.test(path.basename(file)));
const lowerPattern = pattern.toLowerCase();
return files.filter((file) => path.basename(file).toLowerCase().includes(lowerPattern));
}

return files;
Expand Down
4 changes: 3 additions & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ pre-push:
commands:
# Only gates on checks that are currently clean.
# Add each check back here as its backlog is cleared.
# Remaining backlog (CI continue-on-error): no-raw-regex, no-raw-process-env, check-type-casts
# Remaining backlog (CI continue-on-error): check-type-casts
clean-checks:
run: >
bun scripts/lint/no-raw-typeof.ts &&
bun scripts/lint/no-raw-regex.ts &&
bun packages/env/scripts/no-raw-process-env.ts &&
bun scripts/lint/no-circular-deps.ts &&
bun scripts/lint/no-duplicate-deps.ts &&
bun scripts/lint/no-duplicate-guards.ts &&
Expand Down
3 changes: 2 additions & 1 deletion packages/analytics/src/core/data-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DBConfig, QUALITY_WEIGHTS } from './constants';
import { SQLFragments } from './query-builder';

const FILE_EXTENSION_PATTERN = /\.\w+$/;
const TIMESTAMP_UNSAFE_CHARS = /[:.]/g;

// ── Types ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -75,7 +76,7 @@ export class DataExporter {

mkdirSync(outputDir, { recursive: true });

const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const timestamp = new Date().toISOString().replace(TIMESTAMP_UNSAFE_CHARS, '-').slice(0, 19);
const filename = `packrat_export_${timestamp}.${format}`;
const filepath = `${outputDir}/${filename}`;

Expand Down
18 changes: 10 additions & 8 deletions packages/analytics/src/core/entity-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,26 @@ const MAX_BLOCK_SIZE = 5000;
const URL_QUERY_OR_HASH_PATTERN = /[?#].*$/;
const FILE_EXTENSION_PATTERN = /\.\w+$/;
const WHITESPACE_SPLIT_PATTERN = /\s+/;
const GENDER_SIZE_WORDS = /\b(men'?s?|women'?s?|unisex|kids?|youth)\b/gi;
const SIZE_ABBREVIATIONS = /\b(xs|s|m|l|xl|xxl|one size)\b/gi;
const NON_ALPHANUMERIC_SPACES = /[^a-z0-9\s]/g;
const MULTIPLE_SPACES = /\s+/g;
const NON_ALPHANUMERIC = /[^a-z0-9]/g;

// ── Normalization ─────────────────────────────────────────────────────

function normalizeName(name: string): string {
return name
.toLowerCase()
.replace(/\b(men'?s?|women'?s?|unisex|kids?|youth)\b/gi, '')
.replace(/\b(xs|s|m|l|xl|xxl|one size)\b/gi, '')
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, ' ')
.replace(GENDER_SIZE_WORDS, '')
.replace(SIZE_ABBREVIATIONS, '')
.replace(NON_ALPHANUMERIC_SPACES, '')
.replace(MULTIPLE_SPACES, ' ')
.trim();
}

function normalizeBrand(brand: string): string {
return brand
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.trim();
return brand.toLowerCase().replace(NON_ALPHANUMERIC, '').trim();
}

function canonicalId(brand: string, name: string): string {
Expand Down
83 changes: 56 additions & 27 deletions packages/api/src/routes/knowledgeBase/reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,69 @@ import { Elysia, status } from 'elysia';
import { parseHTML } from 'linkedom';
import { z } from 'zod';

// \u2500\u2500 HTML \u2192 Markdown conversion patterns \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
const WHITESPACE_RUNS = /\s{2,}/g;
const NEWLINE_RUNS = /\n{2,}/g;
const TAB_CHAR = /\t/g;
const NON_BREAKING_SPACE = /\u00a0/g;
const LEADING_TRAILING_WHITESPACE = /^\s+|\s+$/g;
const BOILERPLATE_FOOTER = /(We appreciate the time and effort.*|Steve)$/gim;
const HTML_H1 = /<h1[^>]*>([\s\S]*?)<\/h1>/gi;
const HTML_H2 = /<h2[^>]*>([\s\S]*?)<\/h2>/gi;
const HTML_H3 = /<h3[^>]*>([\s\S]*?)<\/h3>/gi;
const HTML_H4 = /<h4[^>]*>([\s\S]*?)<\/h4>/gi;
const HTML_H5 = /<h5[^>]*>([\s\S]*?)<\/h5>/gi;
const HTML_H6 = /<h6[^>]*>([\s\S]*?)<\/h6>/gi;
const HTML_LI = /<li[^>]*>([\s\S]*?)<\/li>/gi;
const HTML_UL = /<ul[^>]*>|<\/ul>/gi;
const HTML_OL = /<ol[^>]*>|<\/ol>/gi;
const HTML_STRONG = /<strong[^>]*>([\s\S]*?)<\/strong>/gi;
const HTML_B = /<b[^>]*>([\s\S]*?)<\/b>/gi;
const HTML_EM = /<em[^>]*>([\s\S]*?)<\/em>/gi;
const HTML_I = /<i[^>]*>([\s\S]*?)<\/i>/gi;
const HTML_A = /<a [^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
const HTML_IMG = /<img [^>]*alt=["']([^"']*)["'][^>]*>/gi;
const HTML_BR = /<br\s*\/?>/gi;
const HTML_P_OPEN = /<p[^>]*>/gi;
const HTML_P_CLOSE = /<\/p>/gi;
const TRIPLE_PLUS_NEWLINES = /\n{3,}/g;
const LINE_LEADING_WHITESPACE = /^[ \t]+/gm;
const HTML_TAGS = /<[^>]*>/g;

// Utility to clean up text for embeddings
function cleanTextForEmbedding(text: string): string {
return text
.replace(/\s{2,}/g, ' ')
.replace(/\n{2,}/g, '\n')
.replace(/\t/g, ' ')
.replace(/\u00a0/g, ' ')
.replace(/^\s+|\s+$/g, '')
.replace(/(We appreciate the time and effort.*|Steve)$/gim, '')
.replace(WHITESPACE_RUNS, ' ')
.replace(NEWLINE_RUNS, '\n')
.replace(TAB_CHAR, ' ')
.replace(NON_BREAKING_SPACE, ' ')
.replace(LEADING_TRAILING_WHITESPACE, '')
.replace(BOILERPLATE_FOOTER, '')
.trim();
}

function htmlToMarkdown(html: string): string {
let result = html
.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '# $1\n')
.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '## $1\n')
.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '### $1\n')
.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, '#### $1\n')
.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, '##### $1\n')
.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, '###### $1\n')
.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n')
.replace(/<ul[^>]*>|<\/ul>/gi, '')
.replace(/<ol[^>]*>|<\/ol>/gi, '')
.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, '**$1**')
.replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, '**$1**')
.replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, '*$1*')
.replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, '*$1*')
.replace(/<a [^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
.replace(/<img [^>]*alt=["']([^"']*)["'][^>]*>/gi, '![$1]()')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<p[^>]*>/gi, '')
.replace(/<\/p>/gi, '\n')
.replace(/\n{3,}/g, '\n\n')
.replace(/^[ \t]+/gm, '')
.replace(HTML_H1, '# $1\n')
.replace(HTML_H2, '## $1\n')
.replace(HTML_H3, '### $1\n')
.replace(HTML_H4, '#### $1\n')
.replace(HTML_H5, '##### $1\n')
.replace(HTML_H6, '###### $1\n')
.replace(HTML_LI, '- $1\n')
.replace(HTML_UL, '')
.replace(HTML_OL, '')
.replace(HTML_STRONG, '**$1**')
.replace(HTML_B, '**$1**')
.replace(HTML_EM, '*$1*')
.replace(HTML_I, '*$1*')
.replace(HTML_A, '[$2]($1)')
.replace(HTML_IMG, '![$1]()')
.replace(HTML_BR, '\n')
.replace(HTML_P_OPEN, '')
.replace(HTML_P_CLOSE, '\n')
.replace(TRIPLE_PLUS_NEWLINES, '\n\n')
.replace(LINE_LEADING_WHITESPACE, '')
.trim();

// Strip any remaining HTML tags in multiple passes to avoid incomplete
Expand All @@ -45,7 +74,7 @@ function htmlToMarkdown(html: string): string {
let iterations = 0;
while (result !== prev && iterations++ < 10) {
prev = result;
result = result.replace(/<[^>]*>/g, '');
result = result.replace(HTML_TAGS, '');
}
return result;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/api/src/routes/packTemplates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { z } from 'zod';
// ---------------------------------------------------------------------------

const QUERY_STRIP_RE = /[?&].*$/;
const STRIP_HYPHENS = /-/g;

function generateContentIdFromUrl(url: string): string {
const normalizedUrl = url.toLowerCase().replace(QUERY_STRIP_RE, '');
Expand Down Expand Up @@ -323,7 +324,7 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' })
: { items: [] as never[] };

const now = new Date();
const templateId = `pt_${crypto.randomUUID().replace(/-/g, '').slice(0, 21)}`;
const templateId = `pt_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 21)}`;

const { newTemplate, insertedItems } = await db.transaction(async (tx) => {
const [createdTemplate] = await tx
Expand All @@ -348,7 +349,7 @@ export const packTemplatesRoutes = new Elysia({ prefix: '/pack-templates' })
const itemRecords = analysis.items.map((detected, index) => {
const catalogMatches = batchResult.items[index] ?? [];
const bestMatch = catalogMatches[0];
const itemId = `pti_${crypto.randomUUID().replace(/-/g, '').slice(0, 21)}`;
const itemId = `pti_${crypto.randomUUID().replace(STRIP_HYPHENS, '').slice(0, 21)}`;

return {
id: itemId,
Expand Down
11 changes: 8 additions & 3 deletions packages/api/src/routes/trailConditions/reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { and, desc, eq, gte, ilike, type SQL } from 'drizzle-orm';
import { Elysia, status } from 'elysia';
import { z } from 'zod';

// ── LIKE-clause escape patterns ───────────────────────────────────────
const LIKE_ESCAPE_BACKSLASH = /\\/g;
const LIKE_ESCAPE_PERCENT = /%/g;
const LIKE_ESCAPE_UNDERSCORE = /_/g;

const CreateReportRequestSchema = z.object({
id: z.string().describe('Client-generated report ID'),
trailName: z.string().min(1),
Expand Down Expand Up @@ -57,9 +62,9 @@ export const trailConditionRoutes = new Elysia()
const normalized = trailName.trim();
if (normalized.length > 0) {
const escaped = normalized
.replace(/\\/g, '\\\\')
.replace(/%/g, '\\%')
.replace(/_/g, '\\_');
.replace(LIKE_ESCAPE_BACKSLASH, '\\\\')
.replace(LIKE_ESCAPE_PERCENT, '\\%')
.replace(LIKE_ESCAPE_UNDERSCORE, '\\_');
conditions.push(ilike(trailConditionReports.trailName, `%${escaped}%`));
}
}
Expand Down
9 changes: 5 additions & 4 deletions packages/api/src/routes/wildlife/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { getPresignedUrl } from '@packrat/api/utils/getPresignedUrl';
import { Elysia, status } from 'elysia';
import { z } from 'zod';

// ── Slug normalization patterns ───────────────────────────────────────
const SPACES_AND_DOTS = /[\s.]+/g;
const NON_SLUG_CHARS = /[^a-z0-9-]/g;

const IdentifyRequestSchema = z.object({
image: z.string().describe('Uploaded image key in R2'),
});
Expand Down Expand Up @@ -55,10 +59,7 @@ export const wildlifeRoutes = new Elysia({ prefix: '/wildlife' }).use(authPlugin

// Map AI results with stable IDs derived from scientific name
const slugify = (name: string) =>
name
.toLowerCase()
.replaceAll(/[\s.]+/g, '-')
.replaceAll(/[^a-z0-9-]/g, '');
name.toLowerCase().replaceAll(SPACES_AND_DOTS, '-').replaceAll(NON_SLUG_CHARS, '');

const results = identification.results.map((r, index) => {
const id = r.scientificName?.trim()
Expand Down
Loading
Loading