Ctrl K
ComunidadGuías…

apps/marketing/scripts/export-hygraph.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// One-off export of the Hygraph blog into markdown files for the blog content
// repo. Writes posts/<slug>.md and downloads every image into images/<slug>/.
//
// Usage (from apps/marketing):
//   dotenvx run -f ../../.env.local -- node scripts/export-hygraph.mjs <output-dir>
//
// Requires HYGRAPH_ENDPOINT and HYGRAPH_TOKEN. Only the PUBLISHED stage is
// exported.
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';

const { HYGRAPH_ENDPOINT, HYGRAPH_TOKEN } = process.env;
const outputDir = process.argv[2];

if (!HYGRAPH_ENDPOINT || !HYGRAPH_TOKEN) {
  console.error('HYGRAPH_ENDPOINT and HYGRAPH_TOKEN must be set');
  process.exit(1);
}

if (!outputDir) {
  console.error('Usage: node scripts/export-hygraph.mjs <output-dir>');
  process.exit(1);
}

const POSTS_QUERY = `
  query ExportBlogPosts($first: Int!, $skip: Int!) {
    blogPosts(first: $first, skip: $skip, orderBy: displayedPublishedAt_DESC) {
      slug
      title
      description
      author
      displayedPublishedAt
      featuredImage {
        url
        fileName
        mimeType
      }
      content {
        raw
      }
    }
  }
`;

const EXTENSIONS = {
  'image/jpeg': 'jpg',
  'image/png': 'png',
  'image/gif': 'gif',
  'image/webp': 'webp',
  'image/svg+xml': 'svg',
  'image/avif': 'avif',
};

async function request(query, variables) {
  const response = await fetch(HYGRAPH_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${HYGRAPH_TOKEN}`,
    },
    body: JSON.stringify({ query, variables }),
  });
  const body = await response.json();
  if (body.errors) {
    throw new Error(JSON.stringify(body.errors));
  }
  return body.data;
}

async function fetchAllPosts() {
  const pageSize = 100;
  const posts = [];
  for (let skip = 0; ; skip += pageSize) {
    const { blogPosts } = await request(POSTS_QUERY, { first: pageSize, skip });
    posts.push(...blogPosts);
    if (blogPosts.length < pageSize) return posts;
  }
}

function extensionFor(mimeType, fileName) {
  if (EXTENSIONS[mimeType]) return EXTENSIONS[mimeType];
  const fromName = path
    .extname(fileName ?? '')
    .slice(1)
    .toLowerCase();
  if (fromName) return fromName;
  throw new Error(`Unknown image type: ${mimeType} (${fileName})`);
}

async function fetchWithRetry(url, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response;
    } catch (error) {
      if (attempt >= attempts) {
        throw new Error(`Failed to download ${url}: ${error.message}`);
      }
    }
  }
}

// Downloads an asset into images/<slug>/<name> and returns the path relative
// to the post file, which is how posts reference images.
async function downloadImage(url, slug, name) {
  const response = await fetchWithRetry(url);
  const dir = path.join(outputDir, 'images', slug);
  await mkdir(dir, { recursive: true });
  await writeFile(
    path.join(dir, name),
    Buffer.from(await response.arrayBuffer())
  );
  return `../images/${slug}/${name}`;
}

// --- Rich text to markdown ---------------------------------------------------
//
// Hygraph content mixes its own Slate nodes (bulleted-list, list-item-child,
// block-quote...) with mdast-style nodes (list, listItem, blockquote, code,
// strong/inlineCode marks) from content that was pasted in as markdown. Both
// are handled here.

function escapeText(text) {
  return (
    text
      .replace(/\\/g, '\\\\')
      .replace(/([*`])/g, '\\$1')
      // Underscores only need escaping when they are not inside a word.
      .replace(/_/g, (match, offset, whole) =>
        /[a-z0-9]/i.test(whole[offset - 1] ?? '') &&
        /[a-z0-9]/i.test(whole[offset + 1] ?? '')
          ? match
          : '\\_'
      )
  );
}

function marksOf(node) {
  return {
    bold: Boolean(node.bold || node.strong),
    italic: Boolean(node.italic),
    code: Boolean(node.code || node.inlineCode),
  };
}

function sameMarks(a, b) {
  const ma = marksOf(a);
  const mb = marksOf(b);
  return ma.bold === mb.bold && ma.italic === mb.italic && ma.code === mb.code;
}

function renderText(node) {
  const { bold, italic, code } = marksOf(node);
  if (code) {
    const text = node.text;
    if (!text) return '';
    const fence = text.includes('`') ? '``' : '`';
    return `${fence}${text}${fence}`;
  }

  // Emphasis markers cannot sit next to whitespace, so keep it outside them.
  const [, leading, inner, trailing] = node.text.match(/^(\s*)(.*?)(\s*)$/s);
  if (!inner) return node.text;
  let text = escapeText(inner);
  if (italic) text = `*${text}*`;
  if (bold) text = `**${text}**`;
  return `${leading}${text}${trailing}`;
}

function renderInline(children = [], context = {}) {
  // Merge adjacent text nodes with identical marks so "**a****b**" does not
  // appear when the editor split a run.
  const merged = [];
  for (const child of children) {
    const previous = merged[merged.length - 1];
    if (
      child.text !== undefined &&
      !child.type &&
      previous?.text !== undefined &&
      !previous.type &&
      sameMarks(previous, child)
    ) {
      merged[merged.length - 1] = {
        ...previous,
        text: previous.text + child.text,
      };
    } else {
      merged.push(child);
    }
  }

  return merged
    .map((child) => {
      if (child.type === 'link') {
        const label = renderInline(child.children, context).trim();
        const title = child.title
          ? ` "${child.title.replace(/"/g, '\\"')}"`
          : '';
        return `[${label || child.href}](${child.href}${title})`;
      }
      if (child.type === 'break') {
        return context.inTable ? ' ' : '\\\n';
      }
      if (child.type) {
        return renderInline(child.children, context);
      }
      return renderText(child);
    })
    .join('');
}

const LIST_TYPES = ['list', 'bulleted-list', 'numbered-list'];

// Flattens a list item into markdown blocks, in order. Items wrap their content
// in list-item-child or paragraph nodes, which may hold nested lists.
function listItemBlocks(nodes) {
  const blocks = [];
  let inline = [];
  const flush = () => {
    const text = renderInline(inline).trim();
    if (text) blocks.push(text);
    inline = [];
  };
  for (const node of nodes) {
    if (LIST_TYPES.includes(node.type)) {
      flush();
      blocks.push(renderBlock(node));
    } else if (node.type === 'list-item-child' || node.type === 'paragraph') {
      flush();
      blocks.push(...listItemBlocks(node.children ?? []));
    } else {
      inline.push(node);
    }
  }
  flush();
  return blocks;
}

function renderList(node, ordered) {
  let index = node.start ?? 1;
  return node.children
    .map((item) => {
      const marker = ordered ? `${index++}.` : '-';
      const indent = ' '.repeat(marker.length + 1);
      const blocks = listItemBlocks(item.children ?? []);
      const content = blocks
        .map((block, i) => {
          if (i === 0) return block;
          // Nested lists hug the line above; further paragraphs need a gap.
          const isList = /^(\d+\.|-) /.test(block);
          return `${isList ? '\n' : '\n\n'}${block}`;
        })
        .join('');
      // Continuation lines must be indented to stay inside the item.
      return `${marker} ${content.replace(/\n(?!\n)/g, `\n${indent}`)}`;
    })
    .join('\n');
}

function renderTable(node) {
  const rows = [];
  for (const section of node.children ?? []) {
    for (const row of section.children ?? []) {
      rows.push(
        (row.children ?? []).map((cell) =>
          renderInline(cell.children, { inTable: true })
            .replace(/\n/g, ' ')
            .replace(/\|/g, '\\|')
            .trim()
        )
      );
    }
  }
  if (!rows.length) return '';

  const width = Math.max(...rows.map((row) => row.length));
  const pad = (row) => [...row, ...Array(width - row.length).fill('')];
  const [header, ...body] = rows.map(pad);
  return [
    `| ${header.join(' | ')} |`,
    `| ${header.map(() => '---').join(' | ')} |`,
    ...body.map((row) => `| ${row.join(' | ')} |`),
  ].join('\n');
}

function renderBlock(node) {
  switch (node.type) {
    case 'paragraph':
      // Paragraphs starting with "## " or "- " were pasted in as markdown, so
      // they are left unescaped and render as the headings and lists intended.
      return renderInline(node.children).trim();
    case 'heading-one':
    case 'heading-two':
    case 'heading-three':
    case 'heading-four':
    case 'heading-five':
    case 'heading-six': {
      const level =
        ['one', 'two', 'three', 'four', 'five', 'six'].indexOf(
          node.type.replace('heading-', '')
        ) + 1;
      return `${'#'.repeat(level)} ${renderInline(node.children).trim()}`;
    }
    case 'bulleted-list':
      return renderList(node, false);
    case 'numbered-list':
      return renderList(node, true);
    case 'list':
      return renderList(node, node.ordered);
    case 'block-quote':
    case 'blockquote': {
      const hasBlocks = (node.children ?? []).some((c) => c.type);
      const inner = hasBlocks
        ? node.children.map((c) => renderBlock(c)).join('\n\n')
        : renderInline(node.children).trim();
      return inner
        .split('\n')
        .map((line) => (line ? `> ${line}` : '>'))
        .join('\n');
    }
    case 'code-block':
    case 'code': {
      const text = (node.children ?? [])
        .map((c) => c.text ?? '')
        .join('')
        .replace(/\n$/, '');
      const lang = node.lang && node.lang !== 'text' ? node.lang : '';
      return `\`\`\`${lang}\n${text}\n\`\`\``;
    }
    case 'thematicBreak':
      return '---';
    case 'table':
      return renderTable(node);
    case 'image':
      return `![${node.title ?? ''}](${node.localPath})`;
    case 'iframe':
      // A bare YouTube URL on its own line is rendered as an embed.
      return node.url;
    case undefined:
      return renderText(node).trim();
    default:
      throw new Error(`Unsupported rich text node: ${node.type}`);
  }
}

async function downloadInlineImages(node, slug) {
  if (node.type === 'image') {
    const name = `${node.handle}.${extensionFor(node.mimeType, node.title)}`;
    node.localPath = await downloadImage(node.src, slug, name);
  }
  for (const child of node.children ?? []) {
    await downloadInlineImages(child, slug);
  }
}

function frontmatter(fields) {
  const lines = Object.entries(fields)
    .filter(([, value]) => value !== undefined && value !== null)
    .map(([key, value]) => `${key}: ${JSON.stringify(value)}`);
  return `---\n${lines.join('\n')}\n---`;
}

const posts = await fetchAllPosts();
await mkdir(path.join(outputDir, 'posts'), { recursive: true });

for (const post of posts) {
  await downloadInlineImages(post.content.raw, post.slug);

  const featuredImage = post.featuredImage
    ? await downloadImage(
        post.featuredImage.url,
        post.slug,
        `cover.${extensionFor(post.featuredImage.mimeType, post.featuredImage.fileName)}`
      )
    : undefined;

  const body = post.content.raw.children
    .map((node) => renderBlock(node))
    .filter(Boolean)
    .join('\n\n');

  const file = `${frontmatter({
    title: post.title,
    description: post.description,
    author: post.author,
    publishedAt: post.displayedPublishedAt,
    featuredImage,
  })}\n\n${body}\n`;

  await writeFile(path.join(outputDir, 'posts', `${post.slug}.md`), file);
  console.log(`Exported ${post.slug}`);
}

console.log(`\nExported ${posts.length} posts to ${path.resolve(outputDir)}`);