viossa.net/apps/vdn-static/src/services/DrupalService.ts
BarnacleBoy 90c7e0cddb
Some checks failed
Deploy to Production / deploy (push) Failing after 33s
feat(i18n,drupal): consolidate locale codes and add Drupal infrastructure
i18n changes:
- Remove qpv locale code, standardize on vp-VL for Viossa
- Remove wp-VL duplicate, keep locale structure clean
- Update langcode types in DrupalService from 'qpv' to 'vp-VL'

Drupal integration additions:
- Add PreviewDrupalService for viewing unpublished content
- Add authentication system with Pinia store and composables
- Add meta tag management system (SEO, OpenGraph, Twitter)
- Add route guards for protected admin routes
- Add login/preview/admin pages

Locale file changes:
- Deleted: qpv.ftl (duplicate of vp-VL.ftl)
- Kept: en_US.ftl, es_LA.ftl, vp_VL.ftl, wp_VL.ftl
2026-05-15 07:07:28 +00:00

291 lines
No EOL
7.6 KiB
TypeScript

/// <reference types="vite/client" />
/**
* Drupal JSON:API Service
* Provides typed access to Drupal content via JSON:API
*/
import type {
JsonApiParams,
JsonApiDocument,
JsonApiResource,
JsonApiLinks,
JsonApiError,
DrupalNodeAttributes,
DrupalTaxonomyTermAttributes,
} from "../types/drupal";
type ContentType = "article" | "page" | "learning_resource";
type Vocabulary = "tags" | "categories";
interface FetchOptions {
baseUrl?: string;
useProxy?: boolean;
}
interface NodeListParams extends JsonApiParams {
contentType: ContentType;
langcode?: "en" | "vp-VL"; // Support English and Viossa translations
includeUnpublished?: boolean; // New parameter for preview mode
}
interface NodeParams extends JsonApiParams {
id: string;
contentType: ContentType;
include?: string[];
langcode?: "en" | "vp-VL";
includeUnpublished?: boolean; // New parameter for preview mode
}
interface TaxonomyParams {
vocabulary: Vocabulary;
langcode?: "en" | "vp-VL";
}
/**
* DrupalService - Client for Drupal JSON:API
*
* Supports two modes:
* 1. Direct: https://cms.viossa.net/jsonapi (requires CORS)
* 2. Proxy: /api/drupal-proxy/ (via viossa.net nginx)
*
* Default uses proxy to avoid CORS issues.
*/
export class DrupalService {
private baseUrl: string;
private authHeaders: Record<string, string> = {};
constructor(options: FetchOptions = {}) {
// Use environment variable or default to proxy path
const envUrl = import.meta.env.VITE_DRUPAL_API_URL;
if (options.baseUrl) {
this.baseUrl = options.baseUrl;
} else if (envUrl) {
this.baseUrl = envUrl;
} else if (options.useProxy === false) {
// Direct access to Drupal
this.baseUrl = "https://cms.viossa.net/jsonapi";
} else {
// Default: use proxy
this.baseUrl = "/api/drupal-proxy";
}
}
/**
* Set authentication headers for subsequent requests
*/
setAuthHeaders(headers: Record<string, string>) {
this.authHeaders = { ...headers };
}
/**
* Clear authentication headers
*/
clearAuthHeaders() {
this.authHeaders = {};
}
/**
* Build query string from JsonApiParams
*/
private buildQueryString(params: JsonApiParams = {}, langcode?: string, includeUnpublished?: boolean): string {
const queryParts: string[] = [];
// Language filter for translated content
if (langcode) {
queryParts.push(`filter[langcode]=${langcode}`);
}
// Include unpublished content in preview mode
if (includeUnpublished) {
queryParts.push(`filter[status][condition][path]=status&filter[status][condition][operator]=IS NULL&filter[status][condition][value]=1`);
}
// Include relationships
if (params.include?.length) {
queryParts.push(`include=${params.include.join(",")}`);
}
// Sparse fieldsets
if (params.fields) {
const fields = Object.entries(params.fields)
.map(([type, fields]) => `fields[${type}]=${fields.join(",")}`)
.join("&");
queryParts.push(fields);
}
// Sorting
if (params.sort?.length) {
queryParts.push(`sort=${params.sort.join(",")}`);
}
// Pagination
if (params.page) {
if (params.page.limit) {
queryParts.push(`page[limit]=${params.page.limit}`);
}
if (params.page.offset) {
queryParts.push(`page[offset]=${params.page.offset}`);
}
if (params.page.cursor) {
queryParts.push(`page[cursor]=${params.page.cursor}`);
}
}
// Filtering
if (params.filter) {
const buildFilter = (obj: Record<string, unknown>, prefix = "filter"): string[] => {
const parts: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const paramKey = `${prefix}[${key}]`;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
parts.push(...buildFilter(value as Record<string, unknown>, paramKey));
} else if (Array.isArray(value)) {
parts.push(`${paramKey}=${value.join(",")}`);
} else {
parts.push(`${paramKey}=${value}`);
}
}
return parts;
};
queryParts.push(...buildFilter(params.filter));
}
return queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
}
/**
* Fetch wrapper with error handling
*/
private async fetchJson<T>(url: string): Promise<T> {
const headers: Record<string, string> = {
"Accept": "application/vnd.api+json",
...this.authHeaders,
};
const response = await fetch(url, {
headers,
credentials: 'include', // Include cookies for session auth
});
if (!response.ok) {
const errorBody = await response.text();
let errors: JsonApiError[] = [];
try {
const errorJson = JSON.parse(errorBody);
errors = errorJson.errors || [];
} catch {
// Not JSON error
}
throw new DrupalApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
errors
);
}
return response.json();
}
/**
* Get nodes by content type
*/
async getNodes<T extends DrupalNodeAttributes = DrupalNodeAttributes>(
params: NodeListParams
): Promise<JsonApiDocument<T>> {
const { contentType, langcode, includeUnpublished, ...queryParams } = params;
const queryString = this.buildQueryString(queryParams, langcode, includeUnpublished);
const url = `${this.baseUrl}/node/${contentType}${queryString}`;
return this.fetchJson<JsonApiDocument<T>>(url);
}
/**
* Get a single node by ID
* Note: Drupal JSON:API uses /jsonapi/node/{type}/{uuid} for individual nodes
*/
async getNode<T extends DrupalNodeAttributes = DrupalNodeAttributes>(
params: NodeParams
): Promise<JsonApiDocument<T>> {
const { id, contentType, include, langcode, includeUnpublished } = params;
const queryParams: JsonApiParams = include ? { include } : {};
const queryString = this.buildQueryString(queryParams, langcode, includeUnpublished);
const url = `${this.baseUrl}/node/${contentType}/${id}${queryString}`;
return this.fetchJson<JsonApiDocument<T>>(url);
}
/**
* Get taxonomy terms by vocabulary
*/
async getTaxonomyTerms(
params: TaxonomyParams
): Promise<JsonApiDocument<DrupalTaxonomyTermAttributes>> {
const { vocabulary, langcode } = params;
const queryString = langcode ? this.buildQueryString({}, langcode) : "";
const url = `${this.baseUrl}/taxonomy_term/${vocabulary}${queryString}`;
return this.fetchJson<JsonApiDocument<DrupalTaxonomyTermAttributes>>(url);
}
/**
* Follow pagination links
*/
async getNextPage<T extends object>(links: JsonApiLinks): Promise<JsonApiDocument<T> | null> {
if (!links.next) return null;
// Handle both string and object link formats
const nextUrl = typeof links.next === "string" ? links.next : links.next;
if (!nextUrl) return null;
// If relative link, prepend base URL
const url = nextUrl.startsWith("/") ? `${this.baseUrl}${nextUrl}` : nextUrl;
return this.fetchJson<JsonApiDocument<T>>(url);
}
/**
* Helper: Extract data array from document
*/
extractData<T extends object>(document: JsonApiDocument<T>): JsonApiResource<T>[] {
return Array.isArray(document.data) ? document.data : [document.data];
}
/**
* Helper: Get attribute from resource
*/
getAttribute<T extends DrupalNodeAttributes>(resource: JsonApiResource<T>): T {
return resource.attributes;
}
/**
* Helper: Check for errors in response
*/
hasErrors(document: JsonApiDocument): boolean {
return document.errors !== undefined && document.errors.length > 0;
}
}
/**
* Custom error class for Drupal API errors
*/
export class DrupalApiError extends Error {
status: number;
errors: JsonApiError[];
constructor(
message: string,
status: number,
errors: JsonApiError[] = []
) {
super(message);
this.name = "DrupalApiError";
this.status = status;
this.errors = errors;
}
}
// Export singleton instance with default configuration
export const drupalService = new DrupalService();