Some checks failed
Deploy to Production / deploy (push) Failing after 33s
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
291 lines
No EOL
7.6 KiB
TypeScript
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(); |