changeset 1702:8ad468cc88d4

add goodjava/bbcode
author Franklin Schmidt <fschmidt@gmail.com>
date Thu, 30 Jun 2022 20:04:34 -0600
parents 077366d117bb
children 3a61451f8130
files src/goodjava/bbcode/BBCode.java src/luan/modules/Parsers.luan src/luan/modules/parsers/BBCode.java src/luan/modules/parsers/BBCodeLuan.java
diffstat 4 files changed, 391 insertions(+), 335 deletions(-) [+]
line wrap: on
line diff
diff -r 077366d117bb -r 8ad468cc88d4 src/goodjava/bbcode/BBCode.java
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/goodjava/bbcode/BBCode.java	Thu Jun 30 20:04:34 2022 -0600
@@ -0,0 +1,334 @@
+package goodjava.bbcode;
+
+import java.util.List;
+import java.util.ArrayList;
+import goodjava.parser.Parser;
+
+
+public final class BBCode {
+
+	public enum Target { HTML, TEXT }
+
+	public interface Quoter {
+		public String quote(Target target,String text,String param);
+	}
+
+	public static final Quoter defaultQuoter = new Quoter() {
+		public String quote(Target target,String text,String param) {
+			StringBuilder sb = new StringBuilder();
+			sb.append( "<blockquote>" );
+			if( param != null ) {
+				sb.append( htmlEncode(param) );
+				sb.append( " wrote:\n" );
+			}
+			sb.append( text );
+			sb.append( "</blockquote>" );
+			return sb.toString();
+		}
+	};
+
+	public Target target = Target.HTML;
+	public Quoter quoter = defaultQuoter;
+	private final Parser parser;
+
+	public BBCode(String text) {
+		this.parser = new Parser(text);
+	}
+
+	public String parse() {
+		StringBuilder sb = new StringBuilder();
+		StringBuilder text = new StringBuilder();
+		while( !parser.endOfInput() ) {
+			String block = parseBlock();
+			if( block != null ) {
+				sb.append( textToString(text) );
+				sb.append(block);
+			} else {
+				text.append( parser.currentChar() );
+				parser.anyChar();
+			}
+		}
+		sb.append( textToString(text) );
+		return sb.toString();
+	}
+
+	private String parseWellFormed() {
+		StringBuilder sb = new StringBuilder();
+		StringBuilder text = new StringBuilder();
+		while( !parser.endOfInput() ) {
+			String block = parseBlock();
+			if( block != null ) {
+				sb.append( textToString(text) );
+				sb.append(block);
+				continue;
+			}
+			if( couldBeTag() )
+				break;
+			text.append( parser.currentChar() );
+			parser.anyChar();
+		}
+		sb.append( textToString(text) );
+		return sb.toString();
+	}
+
+	private String textToString(StringBuilder text) {
+		String s = text.toString();
+		text.setLength(0);
+		if( target == Target.HTML )
+			s = htmlEncode(s);
+		return s;
+	}
+
+	private boolean couldBeTag() {
+		if( parser.currentChar() != '[' )
+			return false;
+		return parser.testIgnoreCase("[b]")
+			|| parser.testIgnoreCase("[/b]")
+			|| parser.testIgnoreCase("[i]")
+			|| parser.testIgnoreCase("[/i]")
+			|| parser.testIgnoreCase("[u]")
+			|| parser.testIgnoreCase("[/u]")
+			|| parser.testIgnoreCase("[url]")
+			|| parser.testIgnoreCase("[url=")
+			|| parser.testIgnoreCase("[/url]")
+			|| parser.testIgnoreCase("[code]")
+			|| parser.testIgnoreCase("[/code]")
+			|| parser.testIgnoreCase("[img]")
+			|| parser.testIgnoreCase("[/img]")
+			|| parser.testIgnoreCase("[color=")
+			|| parser.testIgnoreCase("[/color]")
+			|| parser.testIgnoreCase("[size=")
+			|| parser.testIgnoreCase("[/size]")
+			|| parser.testIgnoreCase("[youtube]")
+			|| parser.testIgnoreCase("[/youtube]")
+			|| parser.testIgnoreCase("[quote]")
+			|| parser.testIgnoreCase("[quote=")
+			|| parser.testIgnoreCase("[/quote]")
+		;
+	}
+
+	private String parseBlock() {
+		if( parser.currentChar() != '[' )
+			return null;
+		String s;
+		s = parseB();  if(s!=null) return s;
+		s = parseI();  if(s!=null) return s;
+		s = parseU();  if(s!=null) return s;
+		s = parseUrl1();  if(s!=null) return s;
+		s = parseUrl2();  if(s!=null) return s;
+		s = parseCode();  if(s!=null) return s;
+		s = parseImg();  if(s!=null) return s;
+		s = parseColor();  if(s!=null) return s;
+		s = parseSize();  if(s!=null) return s;
+		s = parseYouTube();  if(s!=null) return s;
+		s = parseQuote1();  if(s!=null) return s;
+		s = parseQuote2();  if(s!=null) return s;
+		return null;
+	}
+
+	private String parseB() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[b]") )
+			return parser.failure(null);
+		String content = parseWellFormed();
+		if( !parser.matchIgnoreCase("[/b]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<b>"+content+"</b>" : content;
+		return parser.success(rtn);
+	}
+
+	private String parseI() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[i]") )
+			return parser.failure(null);
+		String content = parseWellFormed();
+		if( !parser.matchIgnoreCase("[/i]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<i>"+content+"</i>" : content;
+		return parser.success(rtn);
+	}
+
+	private String parseU() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[u]") )
+			return parser.failure(null);
+		String content = parseWellFormed();
+		if( !parser.matchIgnoreCase("[/u]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<u>"+content+"</u>" : content;
+		return parser.success(rtn);
+	}
+
+	private String parseUrl1() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[url]") )
+			return parser.failure(null);
+		String url = parseRealUrl();
+		if( !parser.matchIgnoreCase("[/url]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<a href='"+url+"'>"+url+"</a>" : url;
+		return parser.success(rtn);
+	}
+
+	private String parseUrl2() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[url=") )
+			return parser.failure(null);
+		String url = parseRealUrl();
+		if( !parser.match(']') )
+			return parser.failure(null);
+		String content = parseWellFormed();
+		if( !parser.matchIgnoreCase("[/url]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<a href='"+url+"'>"+content+"</a>" : content;
+		return parser.success(rtn);
+	}
+
+	private String parseRealUrl() {
+		parser.begin();
+		while( parser.match(' ') );
+		int start = parser.currentIndex();
+		if( !parser.matchIgnoreCase("http") )
+			return parser.failure(null);
+		parser.matchIgnoreCase("s");
+		if( !parser.matchIgnoreCase("://") )
+			return parser.failure(null);
+		while( parser.noneOf(" []'") );
+		String url = parser.textFrom(start);
+		while( parser.match(' ') );
+		return parser.success(url);
+	}
+
+	private String parseCode() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[code]") )
+			return parser.failure(null);
+		int start = parser.currentIndex();
+		while( !parser.testIgnoreCase("[/code]") ) {
+			if( !parser.anyChar() )
+				return parser.failure(null);
+		}
+		String content = parser.textFrom(start);
+		if( !parser.matchIgnoreCase("[/code]") ) throw new RuntimeException();
+		String rtn = target==Target.HTML ? "<code>"+content+"</code>" : content;
+		return parser.success(rtn);
+	}
+
+	private String parseImg() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[img]") )
+			return parser.failure(null);
+		String url = parseRealUrl();
+		if( !parser.matchIgnoreCase("[/img]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<img src='"+url+"'>" : "";
+		return parser.success(rtn);
+	}
+
+	private String parseColor() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[color=") )
+			return parser.failure(null);
+		int start = parser.currentIndex();
+		parser.match('#');
+		while( parser.inCharRange('0','9')
+			|| parser.inCharRange('a','z')
+			|| parser.inCharRange('A','Z')
+		);
+		String color = parser.textFrom(start);
+		if( !parser.match(']') )
+			return parser.failure(null);
+		String content = parseWellFormed();
+		if( !parser.matchIgnoreCase("[/color]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<span style='color: "+color+"'>"+content+"</span>" : content;
+		return parser.success(rtn);
+	}
+
+	private String parseSize() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[size=") )
+			return parser.failure(null);
+		int start = parser.currentIndex();
+		while( parser.match('.') || parser.inCharRange('0','9') );
+		String size = parser.textFrom(start);
+		if( !parser.match(']') )
+			return parser.failure(null);
+		String content = parseWellFormed();
+		if( !parser.matchIgnoreCase("[/size]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<span style='font-size: "+size+"em'>"+content+"</span>" : content;
+		return parser.success(rtn);
+	}
+
+	private String parseYouTube() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[youtube]") )
+			return parser.failure(null);
+		int start = parser.currentIndex();
+		while( parser.inCharRange('0','9')
+			|| parser.inCharRange('a','z')
+			|| parser.inCharRange('A','Z')
+			|| parser.match('-')
+			|| parser.match('_')
+		);
+		String id = parser.textFrom(start);
+		if( id.length()==0 || !parser.matchIgnoreCase("[/youtube]") )
+			return parser.failure(null);
+		String rtn = target==Target.HTML ? "<iframe width='420' height='315' src='https://www.youtube.com/embed/"+id+"' frameborder='0' allowfullscreen></iframe>" : "";
+		return parser.success(rtn);
+	}
+
+	private String parseQuote1() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[quote]") )
+			return parser.failure(null);
+		String content = parseWellFormed();
+		if( !parser.matchIgnoreCase("[/quote]") )
+			return parser.failure(null);
+		String rtn = quoter.quote(target,content,null);
+		return parser.success(rtn);
+	}
+
+	private String parseQuote2() {
+		parser.begin();
+		if( !parser.matchIgnoreCase("[quote=") )
+			return parser.failure(null);
+		List args = new ArrayList();
+		int start = parser.currentIndex();
+		while( parser.noneOf("[]") );
+		String name = parser.textFrom(start).trim();
+		if( !parser.match(']') )
+			return parser.failure(null);
+		String content = parseWellFormed();
+		if( !parser.matchIgnoreCase("[/quote]") )
+			return parser.failure(null);
+		String rtn = quoter.quote(target,content,name);
+		return parser.success(rtn);
+	}
+
+	public static String htmlEncode(String s) {
+		final char[] a = s.toCharArray();
+		StringBuilder buf = new StringBuilder();
+		for( char c : a ) {
+			switch(c) {
+			case '&':
+				buf.append("&amp;");
+				break;
+			case '<':
+				buf.append("&lt;");
+				break;
+			case '>':
+				buf.append("&gt;");
+				break;
+			case '"':
+				buf.append("&quot;");
+				break;
+			default:
+				buf.append(c);
+			}
+		}
+		return buf.toString();
+	}
+
+}
diff -r 077366d117bb -r 8ad468cc88d4 src/luan/modules/Parsers.luan
--- a/src/luan/modules/Parsers.luan	Tue Jun 28 17:59:19 2022 +0300
+++ b/src/luan/modules/Parsers.luan	Thu Jun 30 20:04:34 2022 -0600
@@ -1,5 +1,5 @@
 require "java"
-local BBCode = require "java:luan.modules.parsers.BBCode"
+local BBCodeLuan = require "java:luan.modules.parsers.BBCodeLuan"
 local Csv = require "java:luan.modules.parsers.Csv"
 local Theme = require "java:luan.modules.parsers.Theme"
 local Xml = require "java:luan.modules.parsers.Xml"
@@ -9,8 +9,8 @@
 
 local Parsers = {}
 
-Parsers.bbcode_to_html = BBCode.toHtml
-Parsers.bbcode_to_text = BBCode.toText
+Parsers.bbcode_to_html = BBCodeLuan.toHtml
+Parsers.bbcode_to_text = BBCodeLuan.toText
 Parsers.csv_to_list = Csv.toList
 Parsers.json_string = BasicLuan.json_string
 Parsers.theme_to_luan = Theme.toLuan
diff -r 077366d117bb -r 8ad468cc88d4 src/luan/modules/parsers/BBCode.java
--- a/src/luan/modules/parsers/BBCode.java	Tue Jun 28 17:59:19 2022 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,332 +0,0 @@
-package luan.modules.parsers;
-
-import java.util.List;
-import java.util.ArrayList;
-import luan.Luan;
-import luan.LuanFunction;
-import luan.LuanException;
-import luan.modules.Utils;
-import luan.modules.HtmlLuan;
-import goodjava.parser.Parser;
-
-
-public final class BBCode {
-
-	public static String toHtml(Luan luan,String bbcode,LuanFunction quoter) throws LuanException {
-		return new BBCode(luan,bbcode,quoter,true).parse();
-	}
-
-	public static String toText(Luan luan,String bbcode,LuanFunction quoter) throws LuanException {
-		return new BBCode(luan,bbcode,quoter,false).parse();
-	}
-
-	private final Luan luan;
-	private final Parser parser;
-	private final LuanFunction quoter;
-	private final boolean toHtml;
-
-	private BBCode(Luan luan,String text,LuanFunction quoter,boolean toHtml) throws LuanException {
-		Utils.checkNotNull(text,1);
-//		Utils.checkNotNull(quoter,2);
-		this.luan = luan;
-		this.parser = new Parser(text);
-		this.quoter = quoter;
-		this.toHtml = toHtml;
-	}
-
-	private String parse() throws LuanException {
-		StringBuilder sb = new StringBuilder();
-		StringBuilder text = new StringBuilder();
-		while( !parser.endOfInput() ) {
-			String block = parseBlock();
-			if( block != null ) {
-				sb.append( textToString(text) );
-				sb.append(block);
-			} else {
-				text.append( parser.currentChar() );
-				parser.anyChar();
-			}
-		}
-		sb.append( textToString(text) );
-		return sb.toString();
-	}
-
-	private String parseWellFormed() throws LuanException {
-		StringBuilder sb = new StringBuilder();
-		StringBuilder text = new StringBuilder();
-		while( !parser.endOfInput() ) {
-			String block = parseBlock();
-			if( block != null ) {
-				sb.append( textToString(text) );
-				sb.append(block);
-				continue;
-			}
-			if( couldBeTag() )
-				break;
-			text.append( parser.currentChar() );
-			parser.anyChar();
-		}
-		sb.append( textToString(text) );
-		return sb.toString();
-	}
-
-	private String textToString(StringBuilder text) throws LuanException {
-		String s = text.toString();
-		text.setLength(0);
-		if( toHtml )
-			s = HtmlLuan.encode(s);
-		return s;
-	}
-
-	private boolean couldBeTag() {
-		if( parser.currentChar() != '[' )
-			return false;
-		return parser.testIgnoreCase("[b]")
-			|| parser.testIgnoreCase("[/b]")
-			|| parser.testIgnoreCase("[i]")
-			|| parser.testIgnoreCase("[/i]")
-			|| parser.testIgnoreCase("[u]")
-			|| parser.testIgnoreCase("[/u]")
-			|| parser.testIgnoreCase("[url]")
-			|| parser.testIgnoreCase("[url=")
-			|| parser.testIgnoreCase("[/url]")
-			|| parser.testIgnoreCase("[code]")
-			|| parser.testIgnoreCase("[/code]")
-			|| parser.testIgnoreCase("[img]")
-			|| parser.testIgnoreCase("[/img]")
-			|| parser.testIgnoreCase("[color=")
-			|| parser.testIgnoreCase("[/color]")
-			|| parser.testIgnoreCase("[size=")
-			|| parser.testIgnoreCase("[/size]")
-			|| parser.testIgnoreCase("[youtube]")
-			|| parser.testIgnoreCase("[/youtube]")
-			|| parser.testIgnoreCase("[quote]")
-			|| parser.testIgnoreCase("[quote=")
-			|| parser.testIgnoreCase("[/quote]")
-		;
-	}
-
-	private String parseBlock() throws LuanException {
-		if( parser.currentChar() != '[' )
-			return null;
-		String s;
-		s = parseB();  if(s!=null) return s;
-		s = parseI();  if(s!=null) return s;
-		s = parseU();  if(s!=null) return s;
-		s = parseUrl1();  if(s!=null) return s;
-		s = parseUrl2();  if(s!=null) return s;
-		s = parseCode();  if(s!=null) return s;
-		s = parseImg();  if(s!=null) return s;
-		s = parseColor();  if(s!=null) return s;
-		s = parseSize();  if(s!=null) return s;
-		s = parseYouTube();  if(s!=null) return s;
-		s = parseQuote1();  if(s!=null) return s;
-		s = parseQuote2();  if(s!=null) return s;
-		return null;
-	}
-
-	private String parseB() throws LuanException {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[b]") )
-			return parser.failure(null);
-		String content = parseWellFormed();
-		if( !parser.matchIgnoreCase("[/b]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<b>"+content+"</b>" : content;
-		return parser.success(rtn);
-	}
-
-	private String parseI() throws LuanException {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[i]") )
-			return parser.failure(null);
-		String content = parseWellFormed();
-		if( !parser.matchIgnoreCase("[/i]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<i>"+content+"</i>" : content;
-		return parser.success(rtn);
-	}
-
-	private String parseU() throws LuanException {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[u]") )
-			return parser.failure(null);
-		String content = parseWellFormed();
-		if( !parser.matchIgnoreCase("[/u]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<u>"+content+"</u>" : content;
-		return parser.success(rtn);
-	}
-
-	private String parseUrl1() {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[url]") )
-			return parser.failure(null);
-		String url = parseRealUrl();
-		if( !parser.matchIgnoreCase("[/url]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<a href='"+url+"'>"+url+"</u>" : url;
-		return parser.success(rtn);
-	}
-
-	private String parseUrl2() throws LuanException {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[url=") )
-			return parser.failure(null);
-		String url = parseRealUrl();
-		if( !parser.match(']') )
-			return parser.failure(null);
-		String content = parseWellFormed();
-		if( !parser.matchIgnoreCase("[/url]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<a href='"+url+"'>"+content+"</u>" : content;
-		return parser.success(rtn);
-	}
-
-	private String parseRealUrl() {
-		parser.begin();
-		while( parser.match(' ') );
-		int start = parser.currentIndex();
-		if( !parser.matchIgnoreCase("http") )
-			return parser.failure(null);
-		parser.matchIgnoreCase("s");
-		if( !parser.matchIgnoreCase("://") )
-			return parser.failure(null);
-		while( parser.noneOf(" []'") );
-		String url = parser.textFrom(start);
-		while( parser.match(' ') );
-		return parser.success(url);
-	}
-
-	private String parseCode() {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[code]") )
-			return parser.failure(null);
-		int start = parser.currentIndex();
-		while( !parser.testIgnoreCase("[/code]") ) {
-			if( !parser.anyChar() )
-				return parser.failure(null);
-		}
-		String content = parser.textFrom(start);
-		if( !parser.matchIgnoreCase("[/code]") ) throw new RuntimeException();
-		String rtn = toHtml ? "<code>"+content+"</code>" : content;
-		return parser.success(rtn);
-	}
-
-	private String parseImg() {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[img]") )
-			return parser.failure(null);
-		String url = parseRealUrl();
-		if( !parser.matchIgnoreCase("[/img]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<img src='"+url+"'>" : "";
-		return parser.success(rtn);
-	}
-
-	private String parseColor() throws LuanException {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[color=") )
-			return parser.failure(null);
-		int start = parser.currentIndex();
-		parser.match('#');
-		while( parser.inCharRange('0','9')
-			|| parser.inCharRange('a','z')
-			|| parser.inCharRange('A','Z')
-		);
-		String color = parser.textFrom(start);
-		if( !parser.match(']') )
-			return parser.failure(null);
-		String content = parseWellFormed();
-		if( !parser.matchIgnoreCase("[/color]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<span style='color: "+color+"'>"+content+"</span>" : content;
-		return parser.success(rtn);
-	}
-
-	private String parseSize() throws LuanException {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[size=") )
-			return parser.failure(null);
-		int start = parser.currentIndex();
-		while( parser.match('.') || parser.inCharRange('0','9') );
-		String size = parser.textFrom(start);
-		if( !parser.match(']') )
-			return parser.failure(null);
-		String content = parseWellFormed();
-		if( !parser.matchIgnoreCase("[/size]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<span style='font-size: "+size+"em'>"+content+"</span>" : content;
-		return parser.success(rtn);
-	}
-
-	private String parseYouTube() {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[youtube]") )
-			return parser.failure(null);
-		int start = parser.currentIndex();
-		while( parser.inCharRange('0','9')
-			|| parser.inCharRange('a','z')
-			|| parser.inCharRange('A','Z')
-			|| parser.match('-')
-			|| parser.match('_')
-		);
-		String id = parser.textFrom(start);
-		if( id.length()==0 || !parser.matchIgnoreCase("[/youtube]") )
-			return parser.failure(null);
-		String rtn = toHtml ? "<iframe width='420' height='315' src='https://www.youtube.com/embed/"+id+"' frameborder='0' allowfullscreen></iframe>" : "";
-		return parser.success(rtn);
-	}
-
-	private String quote(Object... args) throws LuanException {
-		if( quoter==null ) {
-			if( toHtml )
-				throw new LuanException("BBCode quoter function not defined");
-			else
-				return "";
-		}
-		Object obj = quoter.call(luan,args);
-		if( !(obj instanceof String) )
-			throw new LuanException("BBCode quoter function returned "+Luan.type(obj)+" but string required");
-		return (String)obj;
-	}
-
-	private String parseQuote1() throws LuanException {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[quote]") )
-			return parser.failure(null);
-		String content = parseWellFormed();
-		if( !parser.matchIgnoreCase("[/quote]") )
-			return parser.failure(null);
-		String rtn = quote(content);
-		return parser.success(rtn);
-	}
-
-	private String parseQuote2() throws LuanException {
-		parser.begin();
-		if( !parser.matchIgnoreCase("[quote=") )
-			return parser.failure(null);
-		List args = new ArrayList();
-		int start = parser.currentIndex();
-		while( parser.noneOf("[];") );
-		String name = parser.textFrom(start).trim();
-		if( name.length() == 0 )
-			return parser.failure(null);
-		args.add(name);
-		while( parser.match(';') ) {
-			start = parser.currentIndex();
-			while( parser.noneOf("[];'") );
-			String src = parser.textFrom(start).trim();
-			args.add(src);
-		}
-		if( !parser.match(']') )
-			return parser.failure(null);
-		String content = parseWellFormed();
-		args.add(0,content);
-		if( !parser.matchIgnoreCase("[/quote]") )
-			return parser.failure(null);
-		String rtn = quote(args.toArray());
-		return parser.success(rtn);
-	}
-
-}
diff -r 077366d117bb -r 8ad468cc88d4 src/luan/modules/parsers/BBCodeLuan.java
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/luan/modules/parsers/BBCodeLuan.java	Thu Jun 30 20:04:34 2022 -0600
@@ -0,0 +1,54 @@
+package luan.modules.parsers;
+
+import java.util.List;
+import java.util.ArrayList;
+import luan.Luan;
+import luan.LuanFunction;
+import luan.LuanException;
+import luan.LuanRuntimeException;
+import luan.modules.Utils;
+import luan.modules.HtmlLuan;
+import goodjava.bbcode.BBCode;
+
+
+public final class BBCodeLuan {
+
+	private static BBCode.Quoter quoter(final Luan luan,final LuanFunction quoterFn) {
+		return new BBCode.Quoter() {
+			public String quote(BBCode.Target target,String text,String param) {
+				try {
+					Object obj = quoterFn.call(luan,text,param);
+					if( !(obj instanceof String) )
+						throw new LuanException("BBCode quoter function returned "+Luan.type(obj)+" but string required");
+					return (String)obj;
+				} catch(LuanException e) {
+					throw new LuanRuntimeException(e);
+				}
+			}
+		};
+	}
+
+	public static String toHtml(Luan luan,String text,LuanFunction quoterFn) throws LuanException {
+		return parse(luan,text,quoterFn,BBCode.Target.HTML);
+	}
+
+	public static String toText(Luan luan,String text,LuanFunction quoterFn) throws LuanException {
+		return parse(luan,text,quoterFn,BBCode.Target.TEXT);
+	}
+
+	private static String parse(Luan luan,String text,LuanFunction quoterFn,BBCode.Target target)
+		throws LuanException
+	{
+		Utils.checkNotNull(text,1);
+		BBCode bbcode = new BBCode(text);
+		bbcode.target = target;
+		if( quoterFn != null )
+			bbcode.quoter = quoter(luan,quoterFn);
+		try {
+			return bbcode.parse();
+		} catch(LuanRuntimeException e) {
+			throw (LuanException)e.getCause();
+		}
+	}
+
+}