/* ** Copyright (c) 2026, the Fossil SCM contributors ** ** This program is free software; you can redistribute it and/or ** modify it under the terms of the Simplified BSD License (also ** known as the "2-Clause License" or "FreeBSD License".) ** ******************************************************************************* ** A small, safe Org-mode renderer for embedded project documentation. ** Executable Org features are intentionally unsupported. */ #include "config.h" #include "org.h" #include typedef struct OrgHeading OrgHeading; typedef struct OrgDoc OrgDoc; struct OrgHeading { char *zText; char *zId; int level; }; struct OrgDoc { char *zCopy; char **azLine; int nLine; int nAlloc; OrgHeading *aHeading; int nHeading; int nHeadingAlloc; char *zTitle; char *zAuthor; char *zDate; int tocDepth; int isWiki; }; static char *org_strndup(const char *z, int n){ char *zOut = fossil_malloc((size_t)n + 1); memcpy(zOut, z, (size_t)n); zOut[n] = 0; return zOut; } static char *org_trimdup(const char *z){ int n; while( fossil_isspace(*z) ) z++; n = (int)strlen(z); while( n>0 && fossil_isspace(z[n-1]) ) n--; return org_strndup(z, n); } static int org_starts_with(const char *z, const char *zPrefix){ return fossil_strnicmp(z, zPrefix, strlen(zPrefix))==0; } static const char *org_directive_value(const char *z, const char *zName){ int n = (int)strlen(zName); if( z[0]!='#' || z[1]!='+' || fossil_strnicmp(z+2,zName,n)!=0 || z[n+2]!=':' ){ return 0; } z += n+3; while( fossil_isspace(*z) ) z++; return z; } static int org_heading(const char *z, int *pLevel, const char **pzText){ int n = 0; while( z[n]=='*' ) n++; if( n==0 || z[n]!=' ' ) return 0; *pLevel = n>6 ? 6 : n; while( z[n]==' ' ) n++; *pzText = z+n; return 1; } static char *org_slug(const char *z){ Blob out = BLOB_INITIALIZER; int i; int pendingDash = 0; for(i=0; z[i]; i++){ unsigned char c = (unsigned char)z[i]; if( c>=0x80 || fossil_isalnum(c) ){ if( pendingDash && blob_size(&out)>0 ) blob_append_char(&out,'-'); blob_append_char(&out, c>=0x80 ? (char)c : (char)fossil_tolower(c)); pendingDash = 0; }else if( c=='-' || fossil_isspace(c) ){ pendingDash = 1; } } if( blob_size(&out)==0 ) blob_append(&out, "section", 7); return blob_str(&out); } static int org_id_exists(OrgDoc *p, const char *zId){ int i; for(i=0; inHeading; i++){ if( fossil_strcmp(p->aHeading[i].zId,zId)==0 ) return 1; } return 0; } static void org_add_heading(OrgDoc *p, int level, const char *zText){ OrgHeading *pH; char *zBase = org_slug(zText); char *zId = fossil_strdup(zBase); int n = 2; while( org_id_exists(p,zId) ){ fossil_free(zId); zId = mprintf("%s-%d", zBase, n++); } fossil_free(zBase); if( p->nHeading>=p->nHeadingAlloc ){ p->nHeadingAlloc = p->nHeadingAlloc*2 + 16; p->aHeading = fossil_realloc(p->aHeading, sizeof(p->aHeading[0])*(size_t)p->nHeadingAlloc); } pH = &p->aHeading[p->nHeading++]; pH->level = level; pH->zText = org_trimdup(zText); pH->zId = zId; } static void org_doc_init(OrgDoc *p, Blob *pIn, int isWiki){ int i; int iStart = 0; int n = blob_size(pIn); memset(p, 0, sizeof(*p)); p->isWiki = isWiki; p->zCopy = org_strndup(blob_buffer(pIn), n); for(i=0; i<=n; i++){ int isCrlf = p->zCopy[i]=='\r' && p->zCopy[i+1]=='\n'; if( p->zCopy[i]=='\r' || p->zCopy[i]=='\n' || p->zCopy[i]==0 ){ const char *zText; const char *zValue; int level; p->zCopy[i] = 0; if( p->nLine>=p->nAlloc ){ p->nAlloc = p->nAlloc*2 + 32; p->azLine = fossil_realloc(p->azLine, sizeof(p->azLine[0])*(size_t)p->nAlloc); } p->azLine[p->nLine++] = p->zCopy+iStart; zText = p->zCopy+iStart; if( (zValue = org_directive_value(zText,"title"))!=0 ){ fossil_free(p->zTitle); p->zTitle = org_trimdup(zValue); }else if( (zValue = org_directive_value(zText,"author"))!=0 ){ fossil_free(p->zAuthor); p->zAuthor = org_trimdup(zValue); }else if( (zValue = org_directive_value(zText,"date"))!=0 ){ fossil_free(p->zDate); p->zDate = org_trimdup(zValue); }else if( (zValue = org_directive_value(zText,"options"))!=0 ){ const char *zToc = zValue; while( *zToc ){ if( org_starts_with(zToc,"toc:") ){ zToc += 4; if( fossil_isdigit(*zToc) ) p->tocDepth = atoi(zToc); else if( fossil_tolower(*zToc)=='t' ) p->tocDepth = 6; else p->tocDepth = 0; break; } while( *zToc && !fossil_isspace(*zToc) ) zToc++; while( fossil_isspace(*zToc) ) zToc++; } }else if( org_heading(zText,&level,&zText) ){ org_add_heading(p, level, zText); } if( isCrlf ){ p->zCopy[++i] = 0; } iStart = i+1; } } if( p->tocDepth>6 ) p->tocDepth = 6; } static void org_doc_reset(OrgDoc *p){ int i; for(i=0; inHeading; i++){ fossil_free(p->aHeading[i].zText); fossil_free(p->aHeading[i].zId); } fossil_free(p->aHeading); fossil_free(p->azLine); fossil_free(p->zTitle); fossil_free(p->zAuthor); fossil_free(p->zDate); fossil_free(p->zCopy); } static const char *org_heading_id(OrgDoc *p, const char *zText){ int i; char *zTrim = org_trimdup(zText); for(i=0; inHeading; i++){ if( fossil_stricmp(zTrim,p->aHeading[i].zText)==0 ){ fossil_free(zTrim); return p->aHeading[i].zId; } } fossil_free(zTrim); return 0; } static int org_is_markup_boundary(char c){ return c==0 || fossil_isspace(c) || strchr(".,;:!?()[]{}'\"",c)!=0; } static int org_find_close(const char *z, int n, int i, char c){ int j; for(j=i+1; ji+1 && !fossil_isspace(z[j-1]) && org_is_markup_boundary(j+11 && z[i+2]=='*' ){ char *zTarget = org_strndup(z+i+3,nTarget-1); const char *zId = org_heading_id(p,zTarget); if( zId ) zHref = zOwned = mprintf("#%s",zId); fossil_free(zTarget); }else if( nTarget>7 && fossil_strnicmp(z+i+2,"http://",7)==0 ){ zHref = zOwned = org_strndup(z+i+2,nTarget); }else if( nTarget>8 && fossil_strnicmp(z+i+2,"https://",8)==0 ){ zHref = zOwned = org_strndup(z+i+2,nTarget); }else if( p->isWiki && nTarget>5 && fossil_strnicmp(z+i+2,"wiki:",5)==0 ){ char *zTarget = org_strndup(z+i+7,nTarget-5); char *zFragment = strchr(zTarget,'#'); char *zSlug = 0; if( zFragment ){ *zFragment++ = 0; zSlug = org_slug(zFragment); } if( zTarget[0] ){ zOwned = zSlug ? mprintf("wiki?name=%T#%T",zTarget,zSlug) : mprintf("wiki?name=%T",zTarget); zHref = zOwned; if( k==j ){ iLabel = i+7; nLabel = nTarget-5; if( zFragment ) nLabel -= (int)strlen(zFragment)+1; } } fossil_free(zSlug); fossil_free(zTarget); }else if( nTarget>5 && fossil_strnicmp(z+i+2,"file:",5)==0 ){ char *zTarget = org_strndup(z+i+7,nTarget-5); if( p->isWiki && org_safe_wiki_file_link(zTarget) ){ zHref = zOwned = mprintf("doc/tip/%T",zTarget); fossil_free(zTarget); }else if( !p->isWiki && org_safe_file_link(zTarget) ){ zHref = zOwned = zTarget; }else{ fossil_free(zTarget); } } if( zHref ){ blob_append(pOut,"",-1); org_inline(p,pOut,z+iLabel,nLabel); blob_append(pOut,"",-1); }else{ org_inline(p,pOut,z+iLabel,nLabel); } fossil_free(zOwned); i = j+2; continue; } } if( strchr("*/_+~=",z[i])!=0 && i+20 ){ const char *zOpen = 0; const char *zClose = 0; switch( z[i] ){ case '*': zOpen=""; zClose=""; break; case '/': zOpen=""; zClose=""; break; case '_': zOpen=""; zClose=""; break; case '+': zOpen=""; zClose=""; break; default: zOpen=""; zClose=""; break; } blob_append(pOut,zOpen,-1); if( z[i]=='~' || z[i]=='=' ) htmlize_to_blob(pOut,z+i+1,j-i-1); else org_inline(p,pOut,z+i+1,j-i-1); blob_append(pOut,zClose,-1); i = j+1; continue; } } if( z[i]=='\\' && i+1=iEnd || !org_list_item(p->azLine[*pi],&itemIndent,&ordered,&zText) ){ return; } zTag = ordered ? "ol" : "ul"; blob_appendf(pOut,"<%s>\n",zTag); while( *piazLine[*pi],&itemIndent,&ordered,&zText) || itemIndent!=indent || fossil_strcmp(zTag,ordered ? "ol" : "ul")!=0 ){ break; } blob_append(pOut,"
  • ",-1); org_inline(p,pOut,zText,-1); (*pi)++; while( *piazLine[*pi],&nextIndent,&nextOrdered,&zNext) && nextIndent>indent ){ org_render_lists(p,pOut,pi,iEnd,nextIndent); } blob_append(pOut,"
  • \n",-1); } blob_appendf(pOut,"\n",zTag); } static int org_is_table_line(const char *z){ while( fossil_isspace(*z) ) z++; return z[0]=='|'; } static int org_is_table_rule(const char *z){ int nDash = 0; while( *z ){ if( *z=='-' ) nDash++; else if( *z!='|' && *z!='+' && *z!=':' && !fossil_isspace(*z) ) return 0; z++; } return nDash>0; } static void org_table_row(OrgDoc *p, Blob *pOut, const char *z, const char *zCell){ while( fossil_isspace(*z) ) z++; if( *z=='|' ) z++; blob_append(pOut,"",-1); while( *z ){ const char *a = z; const char *b = strchr(a,'|'); const char *zNext; if( b==0 ) b = a+strlen(a); zNext = *b ? b+1 : b; while( aa && fossil_isspace(b[-1]) ) b--; blob_appendf(pOut,"<%s>",zCell); org_inline(p,pOut,a,(int)(b-a)); blob_appendf(pOut,"",zCell); z = zNext; } blob_append(pOut,"\n",-1); } static void org_render_table(OrgDoc *p, Blob *pOut, int *pi){ int i = *pi; int iEnd = i; int hasHead; while( iEndnLine && org_is_table_line(p->azLine[iEnd]) ) iEnd++; hasHead = i+1azLine[i+1]); blob_append(pOut,"\n",-1); if( hasHead ){ blob_append(pOut,"\n",-1); org_table_row(p,pOut,p->azLine[i],"th"); blob_append(pOut,"\n\n",-1); i += 2; }else{ blob_append(pOut,"\n",-1); } for(; iazLine[i]) ){ org_table_row(p,pOut,p->azLine[i],"td"); } } blob_append(pOut,"\n
    \n",-1); *pi = iEnd; } static void org_render_block(OrgDoc *p, Blob *pOut, int *pi, const char *zKind, const char *zArgs){ int i = *pi + 1; int iEnd = i; int isSrc = fossil_stricmp(zKind,"src")==0; int isExample = fossil_stricmp(zKind,"example")==0; char *zLang = 0; if( isSrc ){ int n = 0; while( fossil_isspace(*zArgs) ) zArgs++; while( fossil_isalnum(zArgs[n]) || strchr("_+-",zArgs[n])!=0 ) n++; if( n>0 ) zLang = org_strndup(zArgs,n); } while( iEndnLine ){ const char *z = p->azLine[iEnd]; while( fossil_isspace(*z) ) z++; if( org_starts_with(z,"#+end_") && fossil_stricmp(z+6,zKind)==0 ) break; iEnd++; } if( isSrc || isExample ){ blob_appendf(pOut,"
    ",
          isSrc ? " org-src" : " org-example",
          isSrc && zLang ? " has-language" : "",
          isSrc && zLang ? " class=\"language-" : "",
          isSrc && zLang ? zLang : "",
          isSrc && zLang ? "\"" : "");
      }else{
        blob_append(pOut,"
    ",-1);
      }
      for(i=*pi+1; iazLine[i],-1);
        if( i+1
    \n",-1); fossil_free(zLang); *pi = iEndnLine ? iEnd+1 : iEnd; } static void org_render_toc(OrgDoc *p, Blob *pOut){ int i; if( p->tocDepth<=0 ) return; blob_append(pOut,"\n",-1); } static void org_to_html_impl( Blob *pIn, Blob *pTitle, Blob *pOut, int isWiki ){ OrgDoc doc; int i = 0; int iHeading = 0; int inDrawer = 0; org_doc_init(&doc,pIn,isWiki); blob_append(pOut,"
    \n",-1); if( pTitle && doc.zTitle ) blob_append(pTitle,doc.zTitle,-1); if( !pTitle && doc.zTitle ){ blob_append(pOut,"

    ",-1); org_inline(&doc,pOut,doc.zTitle,-1); blob_append(pOut,"

    \n",-1); } if( doc.zAuthor || doc.zDate ){ blob_append(pOut,"
    \n",-1); if( doc.zAuthor ){ blob_append(pOut,"
    Author
    ",-1); org_inline(&doc,pOut,doc.zAuthor,-1); blob_append(pOut,"
    \n",-1); } if( doc.zDate ){ blob_append(pOut,"
    Date
    ",-1); org_inline(&doc,pOut,doc.zDate,-1); blob_append(pOut,"
    \n",-1); } blob_append(pOut,"
    \n",-1); } org_render_toc(&doc,pOut); while( izId,-1); blob_append(pOut,"\">",-1); org_inline(&doc,pOut,zText,-1); blob_appendf(pOut,"\n",level); i++; continue; } if( org_is_table_line(z) ){ org_render_table(&doc,pOut,&i); continue; } if( org_list_item(doc.azLine[i],&indent,&ordered,&zText) ){ org_render_lists(&doc,pOut,&i,doc.nLine,indent); continue; } blob_append(pOut,"

    ",-1); { int nParaLine = 0; while( i\n",-1); } blob_append(pOut,"

    \n",-1); org_doc_reset(&doc); } /* Render the supported, non-executable Org subset as embedded documentation. */ void org_to_html(Blob *pIn, Blob *pTitle, Blob *pOut){ org_to_html_impl(pIn,pTitle,pOut,0); } /* Render Org as a Fossil wiki page. The wiki page name is its title, so an ** optional #+TITLE is parsed but deliberately not emitted a second time. */ void org_to_html_wiki(Blob *pIn, Blob *pOut){ Blob ignoredTitle = BLOB_INITIALIZER; org_to_html_impl(pIn,&ignoredTitle,pOut,1); blob_reset(&ignoredTitle); } /* ** COMMAND: test-org-render ** ** Usage: %fossil test-org-render FILE ?--text? ** ** Render FILE with the built-in Org renderer. With --text, emit the ** cleaned plain text used by full-text search. */ void test_org_render(void){ Blob in = BLOB_INITIALIZER; Blob out = BLOB_INITIALIZER; Blob title = BLOB_INITIALIZER; int asText = find_option("text",0,0)!=0; verify_all_options(); if( g.argc!=3 ) usage("FILE ?--text?"); blob_read_from_file(&in,g.argv[2],ExtFILE); org_to_html(&in,asText ? &title : 0,&out); if( asText ){ Blob plain = BLOB_INITIALIZER; if( blob_size(&title) ) blob_appendf(&plain,"%s\n",blob_str(&title)); html_to_plaintext(blob_str(&out),&plain,HTOT_TRIM); fossil_print("%b\n",&plain); blob_reset(&plain); }else{ fossil_print("%b\n",&out); } blob_reset(&title); blob_reset(&out); blob_reset(&in); }